Refactor code
This commit is contained in:
parent
52eece316b
commit
4d3017912e
3 changed files with 388 additions and 272 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
/*.egg-info/
|
/*.egg-info/
|
||||||
/build/
|
/build/
|
||||||
/dist/
|
/dist/
|
||||||
|
__pycache__
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,241 @@
|
||||||
# -*- coding: utf-8 -*-
|
from itertools import compress
|
||||||
# Copyright (c) 2016 Ted Morin & Keith McCready
|
from struct import *
|
||||||
# See LICENSE.txt for details.
|
from time import sleep
|
||||||
|
|
||||||
"Thread-based monitoring of the stenograph machine."
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from time import sleep
|
from typing import Optional
|
||||||
from plover import log
|
|
||||||
|
from more_itertools import grouper
|
||||||
from plover.machine.base import ThreadedStenotypeBase
|
from plover.machine.base import ThreadedStenotypeBase
|
||||||
|
from plover import log
|
||||||
|
|
||||||
|
'''
|
||||||
|
Packet format:
|
||||||
|
--------------
|
||||||
|
Name: Size: Value Range:
|
||||||
|
----------------------------------
|
||||||
|
Sync 2 bytes "SG"
|
||||||
|
Sequence # 4 bytes 0 - 0xFFFFFFFF (will wrap from 0xFFFFFFFF to 0x00000000)
|
||||||
|
Packet ID 2 bytes As Defined (used as packet type)
|
||||||
|
Data Length 4 bytes 0 - size (limited in most writers to 65536 bytes)
|
||||||
|
Parameter 1 4 bytes As Defined
|
||||||
|
Parameter 2 4 bytes As Defined
|
||||||
|
Parameter 3 4 bytes As Defined
|
||||||
|
Parameter 4 4 bytes As Defined
|
||||||
|
Parameter 5 4 bytes As Defined
|
||||||
|
|
||||||
|
Command 0x13 Read Bytes
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Request (from PC)
|
||||||
|
Description Packet ID Data Length Param 1 Param 2 Param 3 Param 4 Param 5
|
||||||
|
------------------------------------------------------------------------------------------------------
|
||||||
|
Read Bytes 0x0013 00000000 File Offset Byte Count 00000000 00000000 00000000
|
||||||
|
|
||||||
|
-Parameter 1 contains the file offset from which the Mira should start returning bytes (or stroke number * 8 since there are 8 bytes returned per stroke (see details of Response))
|
||||||
|
-Parameter 2 contains the maximum number of bytes the Host wants the Mira to send in response to this request
|
||||||
|
-The Mira will respond to this packet with a successful Read Bytes packet or an Error packet.
|
||||||
|
|
||||||
|
Response (from Mira)
|
||||||
|
Description Packet ID Data Length Param 1 Param 2 Param 3 Param 4 Param 5
|
||||||
|
------------------------------------------------------------------------------------------------------
|
||||||
|
Read Bytes 0x0013 Number of Bytes File Offset 00000000 00000000 00000000 00000000
|
||||||
|
|
||||||
|
-Parameter 1 contains the file offset from which the Mira is returning bytes
|
||||||
|
-For real-time the data is four bytes of steno and 4 bytes of timestamp - 8 bytes per stroke - repeating for the number of strokes returned.
|
||||||
|
The format of the eight bytes will be:
|
||||||
|
-Byte 0: 11^#STKP
|
||||||
|
-Byte 1: 11WHRAO*
|
||||||
|
-Byte 2: 11EUFRPB
|
||||||
|
-Byte 3: 11LGTSDZ
|
||||||
|
-Bytes 4-7: 'timestamp'
|
||||||
|
-The steno is in the (very) old SmartWriter format where the top two bits of each of the four bytes are set to 1 and the bottom 6 bits as set according to the keys pressed.
|
||||||
|
-If the Data Length is zero that indicates there are no more bytes available (real-time).
|
||||||
|
-If the file has been closed (on the writer) an Error packet (error: FileClosed) will be sent in response to Read Bytes.
|
||||||
|
|
||||||
|
Description Packet ID Data Length Param 1 Param 2 Param 3 Param 4 Param 5
|
||||||
|
------------------------------------------------------------------------------------------------------
|
||||||
|
Open File 0x0012 Number of Bytes Disk ID
|
||||||
|
|
||||||
|
- Parameter 1 is the disk ID that the file you wish to open is on (disk A for all intents and purposes)
|
||||||
|
- Data is the filename, probably 'REALTIME.000'
|
||||||
|
'''
|
||||||
|
|
||||||
# ^ is the "stenomark"
|
# ^ is the "stenomark"
|
||||||
STENO_KEY_CHART = (('^', '#', 'S-', 'T-', 'K-', 'P-'),
|
STENO_KEY_CHART = (
|
||||||
('W-', 'H-', 'R-', 'A-', 'O-', '*'),
|
('^', '#', 'S-', 'T-', 'K-', 'P-'),
|
||||||
('-E', '-U', '-F', '-R', '-P', '-B'),
|
('W-', 'H-', 'R-', 'A-', 'O-', '*'),
|
||||||
('-L', '-G', '-T', '-S', '-D', '-Z'),
|
('-E', '-U', '-F', '-R', '-P', '-B'),
|
||||||
)
|
('-L', '-G', '-T', '-S', '-D', '-Z'),
|
||||||
|
)
|
||||||
|
|
||||||
VENDOR_ID = 0x112b
|
VENDOR_ID = 0x112b
|
||||||
MAX_OFFSET = 0xFFFFFFFF
|
MAX_READ = 0x200 # Arbitrary read limit
|
||||||
HEADER_BYTES = 32
|
|
||||||
PACKET_ERROR = 0x06
|
|
||||||
READ_BYTES = 0x13
|
class StenoPacket(object):
|
||||||
|
"""
|
||||||
|
Stenograph StenoPacket helper
|
||||||
|
|
||||||
|
Can be used to create packets to send to the writer, as well as
|
||||||
|
decode a packet from the writer.
|
||||||
|
"""
|
||||||
|
_SYNC = b'SG'
|
||||||
|
|
||||||
|
"""
|
||||||
|
Packet header format:
|
||||||
|
'SG' sequence number packet ID data length p1,p2,p3,p4,p5
|
||||||
|
2 chars 4 bytes 2 bytes 4 bytes 4 bytes each
|
||||||
|
"""
|
||||||
|
_STRUCT_FORMAT = '<2sIH6I'
|
||||||
|
HEADER_SIZE = calcsize(_STRUCT_FORMAT)
|
||||||
|
_STRUCT = Struct(_STRUCT_FORMAT)
|
||||||
|
|
||||||
|
ID_ERROR = 0x6
|
||||||
|
ID_OPEN = 0x12
|
||||||
|
ID_READ = 0x13
|
||||||
|
|
||||||
|
|
||||||
|
sequence_number = 0
|
||||||
|
|
||||||
|
def __init__(self, sequence_number=None, packet_id=0, data_length=None,
|
||||||
|
p1=0, p2=0, p3=0, p4=0, p5=0, data=b''):
|
||||||
|
"""Create a USB Packet
|
||||||
|
|
||||||
|
sequence_number -- ideally unique, if not passed one will be assigned sequentially.
|
||||||
|
|
||||||
|
packet_id -- type of packet.
|
||||||
|
|
||||||
|
data_length -- length of the additional data, calculated if not provided.
|
||||||
|
|
||||||
|
p1, p2, p3, p4, p5 -- 4 byte parameters that have different roles based on packet_id
|
||||||
|
|
||||||
|
data -- data to be appended to the end of the packet, used for steno strokes from the writer.
|
||||||
|
"""
|
||||||
|
if sequence_number is None:
|
||||||
|
sequence_number = StenoPacket.sequence_number
|
||||||
|
StenoPacket._increment_sequence_number()
|
||||||
|
if data_length is None:
|
||||||
|
data_length = len(data)
|
||||||
|
self.sequence_number = sequence_number
|
||||||
|
self.packet_id = packet_id
|
||||||
|
self.data_length = data_length
|
||||||
|
self.p1 = p1
|
||||||
|
self.p2 = p2
|
||||||
|
self.p3 = p3
|
||||||
|
self.p4 = p4
|
||||||
|
self.p5 = p5
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
'StenoPacket(sequence_number=%s, '
|
||||||
|
'packet_id=%s, data_length=%s, '
|
||||||
|
'p1=%s, p2=%s, p3=%s, p4=%s, p5=%s, data=%s)'
|
||||||
|
% (hex(self.sequence_number), hex(self.packet_id),
|
||||||
|
self.data_length, hex(self.p1), hex(self.p2),
|
||||||
|
hex(self.p3), hex(self.p4), hex(self.p5),
|
||||||
|
self.data[:self.data_length])
|
||||||
|
)
|
||||||
|
|
||||||
|
def pack(self):
|
||||||
|
"""Convert this USB Packet into something that can be sent to the writer."""
|
||||||
|
return self._STRUCT.pack(
|
||||||
|
self._SYNC, self.sequence_number, self.packet_id, self.data_length,
|
||||||
|
self.p1, self.p2, self.p3, self.p4, self.p5
|
||||||
|
) + (
|
||||||
|
pack('%ss' % len(self.data), self.data)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _increment_sequence_number():
|
||||||
|
StenoPacket.sequence_number = (StenoPacket.sequence_number + 1) % 0xFFFFFFFF
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def unpack(usb_packet):
|
||||||
|
"""Create a USBPacket from raw data"""
|
||||||
|
packet = StenoPacket(
|
||||||
|
# Drop sync when unpacking.
|
||||||
|
*StenoPacket._STRUCT.unpack(usb_packet[:StenoPacket.HEADER_SIZE])[1:]
|
||||||
|
)
|
||||||
|
if packet.data_length:
|
||||||
|
packet.data, = unpack(
|
||||||
|
'%ss' % packet.data_length,
|
||||||
|
usb_packet[StenoPacket.HEADER_SIZE:StenoPacket.HEADER_SIZE + packet.data_length]
|
||||||
|
)
|
||||||
|
return packet
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_open_request(file_name=b'REALTIME.000', disk_id=b'A'):
|
||||||
|
"""Request to open a file on the writer, defaults to the realtime file."""
|
||||||
|
return StenoPacket(
|
||||||
|
packet_id=StenoPacket.ID_OPEN,
|
||||||
|
p1=ord(disk_id),
|
||||||
|
data=file_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_read_request(file_offset=1, byte_count=MAX_READ):
|
||||||
|
"""Request to read from the writer, defaults to settings required when reading from realtime file."""
|
||||||
|
return StenoPacket(
|
||||||
|
packet_id=StenoPacket.ID_READ,
|
||||||
|
p1=file_offset,
|
||||||
|
p2=byte_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
def strokes(self):
|
||||||
|
"""Get list of strokes represented in this packet's data"""
|
||||||
|
|
||||||
|
# Expecting 8-byte chords (4 bytes of steno, 4 of timestamp.)
|
||||||
|
assert self.data_length % 8 == 0
|
||||||
|
# Steno should only be present on ACTION_READ packets
|
||||||
|
assert self.packet_id == self.ID_READ
|
||||||
|
|
||||||
|
strokes = []
|
||||||
|
for stroke_data in grouper(8, self.data, 0):
|
||||||
|
stroke = []
|
||||||
|
# Get 4 bytes of steno, ignore timestamp.
|
||||||
|
for steno_byte, key_chart_row in zip(stroke_data, STENO_KEY_CHART):
|
||||||
|
assert steno_byte >= 0b11000000
|
||||||
|
# Only interested in right 6 values
|
||||||
|
key_mask = [int(i) for i in bin(steno_byte)[-6:]]
|
||||||
|
stroke.extend(compress(key_chart_row, key_mask))
|
||||||
|
if stroke:
|
||||||
|
strokes.append(stroke)
|
||||||
|
return strokes
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractStenographMachine(object):
|
||||||
|
"""Simple interface to connect with and send data to a Stenograph machine"""
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""Connect to machine, returns connection status"""
|
||||||
|
raise NotImplementedError('connect() is not implemented')
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Disconnect from the machine"""
|
||||||
|
raise NotImplementedError('disconnect() is not implemented')
|
||||||
|
|
||||||
|
def send_receive(self, request: StenoPacket) -> Optional[StenoPacket]:
|
||||||
|
"""Send a StenoPacket to the machine and return the response or None"""
|
||||||
|
raise NotImplementedError('send_receive() is not implemented')
|
||||||
|
|
||||||
if sys.platform.startswith('win32'):
|
if sys.platform.startswith('win32'):
|
||||||
from ctypes import *
|
from ctypes import *
|
||||||
from ctypes.wintypes import DWORD, HANDLE, WORD, BYTE
|
from ctypes.wintypes import DWORD, HANDLE, BYTE
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
class GUID(Structure):
|
# Class GUID for Stenograph USB Writer
|
||||||
_fields_ = [("Data1", DWORD),
|
|
||||||
("Data2", WORD),
|
|
||||||
("Data3", WORD),
|
|
||||||
("Data4", BYTE * 8)]
|
|
||||||
|
|
||||||
|
|
||||||
class SP_DEVICE_INTERFACE_DATA(Structure):
|
|
||||||
_fields_ = [('cbSize',DWORD),
|
|
||||||
('InterfaceClassGuid', BYTE * 16),
|
|
||||||
('Flags', DWORD),
|
|
||||||
('Reserved', POINTER(c_ulonglong))]
|
|
||||||
|
|
||||||
class USB_Packet(Structure):
|
|
||||||
_fields_ = [
|
|
||||||
# Hack to pack structure correctly (without importing 'struct')
|
|
||||||
("SyncSeqType", c_ubyte * 8),
|
|
||||||
("uiDataLen", DWORD),
|
|
||||||
("uiFileOffset", DWORD),
|
|
||||||
("uiByteCount", DWORD),
|
|
||||||
("uiParam3", DWORD),
|
|
||||||
("uiParam4", DWORD),
|
|
||||||
("uiParam5", DWORD),
|
|
||||||
("data", c_ubyte * 1024)
|
|
||||||
] # limiting data to 1024 bytes (should only ask for 512 at a time)
|
|
||||||
|
|
||||||
# Class GUID / UUID for Stenograph USB Writer
|
|
||||||
USB_WRITER_GUID = uuid.UUID('{c5682e20-8059-604a-b761-77c4de9d5dbf}')
|
USB_WRITER_GUID = uuid.UUID('{c5682e20-8059-604a-b761-77c4de9d5dbf}')
|
||||||
|
|
||||||
|
class DeviceInterfaceData(Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('cbSize', DWORD),
|
||||||
|
('InterfaceClassGuid', BYTE * 16),
|
||||||
|
('Flags', DWORD),
|
||||||
|
('Reserved', POINTER(c_ulonglong))
|
||||||
|
]
|
||||||
|
|
||||||
# For Windows we directly call Windows API functions
|
# For Windows we directly call Windows API functions
|
||||||
SetupDiGetClassDevs = windll.setupapi.SetupDiGetClassDevsA
|
SetupDiGetClassDevs = windll.setupapi.SetupDiGetClassDevsA
|
||||||
SetupDiEnumDeviceInterfaces = windll.setupapi.SetupDiEnumDeviceInterfaces
|
SetupDiEnumDeviceInterfaces = windll.setupapi.SetupDiEnumDeviceInterfaces
|
||||||
|
|
@ -67,30 +247,23 @@ if sys.platform.startswith('win32'):
|
||||||
CloseHandle = windll.kernel32.CloseHandle
|
CloseHandle = windll.kernel32.CloseHandle
|
||||||
GetLastError = windll.kernel32.GetLastError
|
GetLastError = windll.kernel32.GetLastError
|
||||||
|
|
||||||
# USB Writer 'defines'
|
|
||||||
INVALID_HANDLE_VALUE = -1
|
INVALID_HANDLE_VALUE = -1
|
||||||
ERROR_INSUFFICIENT_BUFFER = 122
|
ERROR_INSUFFICIENT_BUFFER = 122
|
||||||
USB_NO_RESPONSE = -9
|
|
||||||
RT_FILE_ENDED_ON_WRITER = -8
|
|
||||||
|
|
||||||
class WindowsStenographMachine(object):
|
class StenographMachine(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Allocate memory for sending and receiving USB data
|
|
||||||
self._host_packet = USB_Packet()
|
|
||||||
self._writer_packet = USB_Packet()
|
|
||||||
|
|
||||||
self._sequence_number = 0
|
|
||||||
self._connected = False
|
|
||||||
self._usb_device = HANDLE(0)
|
self._usb_device = HANDLE(0)
|
||||||
|
self._read_buffer = create_string_buffer(MAX_READ + StenoPacket.HEADER_SIZE)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _open_device_instance(device_info, guid):
|
def _open_device_instance(device_info, guid):
|
||||||
dev_interface_data = SP_DEVICE_INTERFACE_DATA()
|
dev_interface_data = DeviceInterfaceData()
|
||||||
dev_interface_data.cbSize=sizeof(dev_interface_data)
|
dev_interface_data.cbSize = sizeof(dev_interface_data)
|
||||||
|
|
||||||
status = SetupDiEnumDeviceInterfaces(
|
status = SetupDiEnumDeviceInterfaces(
|
||||||
device_info, None, guid.bytes, 0, byref(dev_interface_data))
|
device_info, None, guid.bytes, 0, byref(dev_interface_data))
|
||||||
if status == 0:
|
if status == 0:
|
||||||
|
log.debug('status is zero')
|
||||||
return INVALID_HANDLE_VALUE
|
return INVALID_HANDLE_VALUE
|
||||||
|
|
||||||
request_length = DWORD(0)
|
request_length = DWORD(0)
|
||||||
|
|
@ -103,17 +276,19 @@ if sys.platform.startswith('win32'):
|
||||||
pointer(request_length),
|
pointer(request_length),
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
error = GetLastError()
|
err = GetLastError()
|
||||||
if error != ERROR_INSUFFICIENT_BUFFER:
|
if err != ERROR_INSUFFICIENT_BUFFER:
|
||||||
|
log.debug('last error not insufficient buffer')
|
||||||
return INVALID_HANDLE_VALUE
|
return INVALID_HANDLE_VALUE
|
||||||
|
|
||||||
characters = request_length.value
|
characters = request_length.value
|
||||||
|
|
||||||
class PSP_INTERFACE_DEVICE_DETAIL_DATA(Structure):
|
class DeviceDetailData(Structure):
|
||||||
_fields_ = [('cbSize', DWORD),
|
_fields_ = [('cbSize', DWORD),
|
||||||
('DevicePath', c_char * characters)]
|
('DevicePath', c_char * characters)]
|
||||||
dev_detail_data = PSP_INTERFACE_DEVICE_DETAIL_DATA()
|
|
||||||
dev_detail_data.cbSize = 5 # DWORD + 4 byte pointer
|
dev_detail_data = DeviceDetailData()
|
||||||
|
dev_detail_data.cbSize = 5
|
||||||
|
|
||||||
# Now put the actual detail data into the buffer
|
# Now put the actual detail data into the buffer
|
||||||
status = SetupDiGetInterfaceDeviceDetail(
|
status = SetupDiGetInterfaceDeviceDetail(
|
||||||
|
|
@ -121,93 +296,52 @@ if sys.platform.startswith('win32'):
|
||||||
characters, pointer(request_length), None
|
characters, pointer(request_length), None
|
||||||
)
|
)
|
||||||
if not status:
|
if not status:
|
||||||
|
log.debug('not status')
|
||||||
return INVALID_HANDLE_VALUE
|
return INVALID_HANDLE_VALUE
|
||||||
|
log.debug('okay, creating file')
|
||||||
return CreateFile(
|
return CreateFile(
|
||||||
dev_detail_data.DevicePath,
|
dev_detail_data.DevicePath,
|
||||||
0xC0000000, 0x3, 0, 0x3, 0x80, 0
|
0xC0000000, 0x3, 0, 0x3, 0x80, 0
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _open_device_by_class_interface_and_instance(classguid):
|
def _open_device_by_class_interface_and_instance(class_guid):
|
||||||
device_info = SetupDiGetClassDevs(classguid.bytes, 0, 0, 0x12)
|
device_info = SetupDiGetClassDevs(class_guid.bytes, 0, 0, 0x12)
|
||||||
if device_info == INVALID_HANDLE_VALUE:
|
if device_info == INVALID_HANDLE_VALUE:
|
||||||
|
log.debug('dev info is invalid handle')
|
||||||
return INVALID_HANDLE_VALUE
|
return INVALID_HANDLE_VALUE
|
||||||
|
|
||||||
usb_device = WindowsStenographMachine._open_device_instance(
|
usb_device = StenographMachine._open_device_instance(
|
||||||
device_info, classguid)
|
device_info, class_guid)
|
||||||
return usb_device
|
return usb_device
|
||||||
|
|
||||||
def _usb_write_packet(self):
|
def _usb_write_packet(self, request):
|
||||||
self._host_packet.SyncSeqType[0] = ord('S')
|
|
||||||
self._host_packet.SyncSeqType[1] = ord('G')
|
|
||||||
self._host_packet.SyncSeqType[2] = self._sequence_number % 255
|
|
||||||
self._host_packet.SyncSeqType[6] = READ_BYTES
|
|
||||||
if self._usb_device == INVALID_HANDLE_VALUE:
|
|
||||||
return 0
|
|
||||||
bytes_written = DWORD(0)
|
bytes_written = DWORD(0)
|
||||||
|
request_packet = request.pack()
|
||||||
WriteFile(
|
WriteFile(
|
||||||
self._usb_device,
|
self._usb_device,
|
||||||
byref(self._host_packet),
|
request_packet,
|
||||||
32 + self._host_packet.uiDataLen,
|
StenoPacket.HEADER_SIZE + request.data_length,
|
||||||
byref(bytes_written),
|
byref(bytes_written),
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
return bytes_written.value
|
return bytes_written.value
|
||||||
|
|
||||||
def _usb_read_packet(self):
|
def _usb_read_packet(self):
|
||||||
assert self._usb_device != INVALID_HANDLE_VALUE, 'device not open'
|
bytes_read = DWORD(0)
|
||||||
|
ReadFile(
|
||||||
bytes_read = DWORD(0)
|
|
||||||
# Always read this maximum amount.
|
|
||||||
# The header will tell me how much to pay attention to.
|
|
||||||
ReadFile(
|
|
||||||
self._usb_device,
|
self._usb_device,
|
||||||
byref(self._writer_packet),
|
byref(self._read_buffer),
|
||||||
32 + 1024,
|
MAX_READ + StenoPacket.HEADER_SIZE,
|
||||||
byref(bytes_read),
|
byref(bytes_read),
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
if bytes_read.value == 0:
|
# Return None if not enough data was read.
|
||||||
return 0
|
if bytes_read.value < StenoPacket.HEADER_SIZE:
|
||||||
if bytes_read.value < 32: # returned without a full packet
|
return None
|
||||||
return 0
|
|
||||||
return 32 + self._writer_packet.uiDataLen
|
|
||||||
|
|
||||||
def _read_steno(self, file_offset):
|
writer_packet = StenoPacket.unpack(self._read_buffer)
|
||||||
self._host_packet.SyncSeqType[6] = READ_BYTES
|
return writer_packet
|
||||||
self._host_packet.uiDataLen = 0
|
|
||||||
self._host_packet.uiParam3 = 0
|
|
||||||
self._host_packet.uiParam4 = 0
|
|
||||||
self._host_packet.uiParam5 = 0
|
|
||||||
self._host_packet.uiFileOffset = file_offset
|
|
||||||
self._host_packet.uiByteCount = 512
|
|
||||||
if self._usb_write_packet() == 0:
|
|
||||||
return USB_NO_RESPONSE
|
|
||||||
|
|
||||||
# listen for response
|
|
||||||
amount_read = self._usb_read_packet()
|
|
||||||
|
|
||||||
if amount_read > 0:
|
|
||||||
amount_read -= HEADER_BYTES
|
|
||||||
else:
|
|
||||||
# No bytes means we've probably disconnected.
|
|
||||||
return USB_NO_RESPONSE
|
|
||||||
|
|
||||||
# If the sequence number is not the same it is junk
|
|
||||||
if (self._writer_packet.SyncSeqType[2] ==
|
|
||||||
self._host_packet.SyncSeqType[2]):
|
|
||||||
self._sequence_number += 1
|
|
||||||
|
|
||||||
if self._writer_packet.SyncSeqType[6] == READ_BYTES:
|
|
||||||
return self._writer_packet.uiDataLen
|
|
||||||
else:
|
|
||||||
# Could check the error code for more specific errors here
|
|
||||||
if self._writer_packet.SyncSeqType[6] == PACKET_ERROR:
|
|
||||||
return RT_FILE_ENDED_ON_WRITER
|
|
||||||
else:
|
|
||||||
self._usb_read_packet() # toss out any junk
|
|
||||||
return USB_NO_RESPONSE
|
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
CloseHandle(self._usb_device)
|
CloseHandle(self._usb_device)
|
||||||
|
|
@ -222,77 +356,63 @@ if sys.platform.startswith('win32'):
|
||||||
USB_WRITER_GUID))
|
USB_WRITER_GUID))
|
||||||
return self._usb_device != INVALID_HANDLE_VALUE
|
return self._usb_device != INVALID_HANDLE_VALUE
|
||||||
|
|
||||||
def read(self, file_offset):
|
def send_receive(self, request):
|
||||||
result = self._read_steno(file_offset)
|
assert self._usb_device != INVALID_HANDLE_VALUE, 'device not open'
|
||||||
print('result --', result)
|
written = self._usb_write_packet(request)
|
||||||
sys.stdout.flush()
|
if written < StenoPacket.HEADER_SIZE:
|
||||||
if result > 0: # Got one or more steno strokes
|
# We were not able to write the request.
|
||||||
print('got something:', result)
|
return None
|
||||||
sys.stdout.flush()
|
writer_packet = self._usb_read_packet()
|
||||||
return self._writer_packet.data[:result]
|
return writer_packet
|
||||||
elif not result:
|
|
||||||
return []
|
|
||||||
elif result == RT_FILE_ENDED_ON_WRITER:
|
|
||||||
raise EOFError(
|
|
||||||
'No open file on writer, open file and reconnect')
|
|
||||||
elif result == USB_NO_RESPONSE:
|
|
||||||
# Prompt a reconnect
|
|
||||||
raise IOError('No response from Stenograph device')
|
|
||||||
StenographMachine = WindowsStenographMachine
|
|
||||||
else:
|
else:
|
||||||
from usb import core, util
|
from usb import core, util
|
||||||
|
|
||||||
class LibUSBStenographMachine(object):
|
class StenographMachine(AbstractStenographMachine):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
super(StenographMachine, self).__init__()
|
||||||
self._usb_device = None
|
self._usb_device = None
|
||||||
self._endpoint_in = None
|
self._endpoint_in = None
|
||||||
self._endpoint_out = None
|
self._endpoint_out = None
|
||||||
self._sequence_number = 0
|
|
||||||
self._packet = bytearray(
|
|
||||||
[0x53, 0x47, # SG → sync (static)
|
|
||||||
0, 0, 0, 0, # Sequence number
|
|
||||||
READ_BYTES, 0, # Action (static)
|
|
||||||
0, 0, 0, 0, # Data length
|
|
||||||
0, 0, 0, 0, # File offset
|
|
||||||
0, 0x02, 0, 0, # Requested byte count (static 512)
|
|
||||||
0, 0, 0, 0, # Parameter 3
|
|
||||||
0, 0, 0, 0, # Parameter 4
|
|
||||||
0, 0, 0, 0, # Parameter 5
|
|
||||||
]
|
|
||||||
)
|
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
|
"""Attempt to and return connection"""
|
||||||
# Disconnect device if it's already connected.
|
# Disconnect device if it's already connected.
|
||||||
if self._connected:
|
if self._connected:
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
|
|
||||||
# Find the device by the vendor ID.
|
# Find the device by the vendor ID.
|
||||||
self._usb_device = core.find(idVendor=VENDOR_ID)
|
usb_device = core.find(idVendor=VENDOR_ID)
|
||||||
if not self._usb_device: # Device not found
|
if not usb_device: # Device not found
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
# Copy the default configuration.
|
# Copy the default configuration.
|
||||||
self._usb_device.set_configuration()
|
usb_device.set_configuration()
|
||||||
config = self._usb_device.get_active_configuration()
|
config = usb_device.get_active_configuration()
|
||||||
interface = config[(0, 0)]
|
interface = config[(0, 0)]
|
||||||
|
|
||||||
# Get the write endpoint.
|
# Get the write endpoint.
|
||||||
self._endpoint_out = util.find_descriptor(
|
endpoint_out = util.find_descriptor(
|
||||||
interface,
|
interface,
|
||||||
custom_match=lambda e:
|
custom_match=lambda e:
|
||||||
util.endpoint_direction(e.bEndpointAddress) ==
|
util.endpoint_direction(e.bEndpointAddress) ==
|
||||||
util.ENDPOINT_OUT)
|
util.ENDPOINT_OUT
|
||||||
assert self._endpoint_out is not None, 'cannot find write endpoint'
|
)
|
||||||
|
assert endpoint_out is not None, 'cannot find write endpoint'
|
||||||
|
|
||||||
# Get the read endpoint.
|
# Get the read endpoint.
|
||||||
self._endpoint_in = util.find_descriptor(
|
endpoint_in = util.find_descriptor(
|
||||||
interface,
|
interface,
|
||||||
custom_match=lambda e:
|
custom_match=lambda e:
|
||||||
util.endpoint_direction(e.bEndpointAddress) ==
|
util.endpoint_direction(e.bEndpointAddress) ==
|
||||||
util.ENDPOINT_IN)
|
util.ENDPOINT_IN
|
||||||
assert self._endpoint_in is not None, 'cannot find read endpoint'
|
)
|
||||||
|
assert endpoint_in is not None, 'cannot find read endpoint'
|
||||||
|
|
||||||
|
self._usb_device = usb_device
|
||||||
|
self._endpoint_in = endpoint_in
|
||||||
|
self._endpoint_out = endpoint_out
|
||||||
self._connected = True
|
self._connected = True
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
|
@ -303,34 +423,50 @@ else:
|
||||||
self._endpoint_in = None
|
self._endpoint_in = None
|
||||||
self._endpoint_out = None
|
self._endpoint_out = None
|
||||||
|
|
||||||
def read(self, file_offset):
|
def send_receive(self, request):
|
||||||
assert self._connected, 'cannot read from machine if not connected'
|
assert self._connected, 'cannot read from machine if not connected'
|
||||||
self._sequence_number = (self._sequence_number + 1) % MAX_OFFSET
|
|
||||||
for i in range(4):
|
|
||||||
self._packet[2 + i] = self._sequence_number >> 8 * i & 255
|
|
||||||
for i in range(4):
|
|
||||||
self._packet[12 + i] = file_offset >> 8 * i & 255
|
|
||||||
try:
|
try:
|
||||||
self._endpoint_out.write(self._packet)
|
self._endpoint_out.write(request.pack())
|
||||||
response = self._endpoint_in.read(1024, 3000)
|
response = self._endpoint_in.read(
|
||||||
|
MAX_READ + StenoPacket.HEADER_SIZE, 3000)
|
||||||
except core.USBError:
|
except core.USBError:
|
||||||
raise IOError('Machine read or write failed')
|
return None
|
||||||
else:
|
else:
|
||||||
if response and len(response) >= HEADER_BYTES:
|
if response and len(response) >= StenoPacket.HEADER_SIZE:
|
||||||
writer_action = response[6]
|
writer_packet = StenoPacket.unpack(response)
|
||||||
if writer_action == PACKET_ERROR:
|
# Ignore data if sequence numbers don't match.
|
||||||
raise EOFError(
|
if writer_packet.sequence_number == request.sequence_number:
|
||||||
'No open file on writer, open file and reconnect')
|
return writer_packet
|
||||||
elif writer_action == READ_BYTES:
|
return None
|
||||||
return response[HEADER_BYTES:]
|
|
||||||
return response
|
|
||||||
StenographMachine = LibUSBStenographMachine
|
class ProtocolViolationException(Exception):
|
||||||
|
"""The writer did something unexpected"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnableToPerformRequestException(Exception):
|
||||||
|
"""The writer cannot perform the action requested"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FileNotAvailableException(Exception):
|
||||||
|
"""The writer cannot read from the current file"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NoRealtimeFileException(Exception):
|
||||||
|
"""The realtime file doesn't exist, likely because the user hasn't started writing"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FinishedReadingClosedFileException(Exception):
|
||||||
|
"""The closed file being read is complete and cannot be read further"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Stenograph(ThreadedStenotypeBase):
|
class Stenograph(ThreadedStenotypeBase):
|
||||||
|
|
||||||
KEYMAP_MACHINE_TYPE = 'Stentura'
|
|
||||||
|
|
||||||
KEYS_LAYOUT = '''
|
KEYS_LAYOUT = '''
|
||||||
# # # # # # # # # #
|
# # # # # # # # # #
|
||||||
S- T- P- H- * -F -P -L -T -D
|
S- T- P- H- * -F -P -L -T -D
|
||||||
|
|
@ -338,38 +474,12 @@ class Stenograph(ThreadedStenotypeBase):
|
||||||
A- O- -E -U
|
A- O- -E -U
|
||||||
^
|
^
|
||||||
'''
|
'''
|
||||||
|
KEYMAP_MACHINE_TYPE = 'Stentura'
|
||||||
|
|
||||||
def __init__(self, params):
|
def __init__(self, params):
|
||||||
super(Stenograph, self).__init__()
|
super(Stenograph, self).__init__()
|
||||||
self._machine = StenographMachine()
|
self._machine = StenographMachine()
|
||||||
|
|
||||||
# State variables used while reading steno from machine
|
|
||||||
"""
|
|
||||||
Stenograph writers have "files" which are indexed from 0.
|
|
||||||
Each line is a stroke. When the machine is connected, we
|
|
||||||
don't know what stroke the stenographer is on. We can't
|
|
||||||
assume zero because there may already be content from before.
|
|
||||||
Strategy is to read up the indexes until we reach zero. Then,
|
|
||||||
we consider ourselves "realtime" and further data should be
|
|
||||||
interpreted.
|
|
||||||
"""
|
|
||||||
self._realtime = False
|
|
||||||
"""
|
|
||||||
There is a bug in the stenograph firmware. After the steno
|
|
||||||
"file" is closed on the machine, we receive a flag to tell us.
|
|
||||||
However, straight after we start receiving 0 length responses,
|
|
||||||
which are the same format as what we'd usually use to tell whether
|
|
||||||
the machine is in realtime or not. If the stenographer opens a file
|
|
||||||
that has previous content in it, we will then blurt out all that
|
|
||||||
content.
|
|
||||||
|
|
||||||
The strategy here is to take note of the first response that is exactly
|
|
||||||
8 bytes -- a single stroke -- before beginning output. Combined with the
|
|
||||||
0 length stroke check, hopefully we will not accidentally read back and
|
|
||||||
blurt an entire steno file."""
|
|
||||||
self._read_exactly_8 = False
|
|
||||||
self._file_offset = 0
|
|
||||||
|
|
||||||
def _on_stroke(self, keys):
|
def _on_stroke(self, keys):
|
||||||
steno_keys = self.keymap.keys_to_actions(keys)
|
steno_keys = self.keymap.keys_to_actions(keys)
|
||||||
if steno_keys:
|
if steno_keys:
|
||||||
|
|
@ -394,7 +504,7 @@ class Stenograph(ThreadedStenotypeBase):
|
||||||
log.warning('Libusb must be installed.')
|
log.warning('Libusb must be installed.')
|
||||||
self._error()
|
self._error()
|
||||||
except AssertionError as e:
|
except AssertionError as e:
|
||||||
log.warning('Error connecting: %s', str(e))
|
log.warning('Error connecting: %s', e)
|
||||||
self._error()
|
self._error()
|
||||||
finally:
|
finally:
|
||||||
return connected
|
return connected
|
||||||
|
|
@ -407,78 +517,81 @@ class Stenograph(ThreadedStenotypeBase):
|
||||||
connected = self._connect_machine()
|
connected = self._connect_machine()
|
||||||
return connected
|
return connected
|
||||||
|
|
||||||
def _reset_state(self):
|
def _send_receive(self, request):
|
||||||
self._realtime = False
|
"""Send a StenoPacket and return the response or raise exceptions."""
|
||||||
self._read_exactly_8 = False
|
log.debug('Requesting from Stenograph: %s', request)
|
||||||
self._file_offset = 0
|
response = self._machine.send_receive(request)
|
||||||
|
log.debug('Response from Stenograph: %s', response)
|
||||||
|
if response is None:
|
||||||
|
"""No response implies device connection issue."""
|
||||||
|
raise IOError()
|
||||||
|
elif response.packet_id == StenoPacket.ID_ERROR:
|
||||||
|
"""Writer may reply with an error packet"""
|
||||||
|
error_number = response.p1
|
||||||
|
if error_number == 3:
|
||||||
|
raise UnableToPerformRequestException()
|
||||||
|
elif error_number == 7:
|
||||||
|
raise FileNotAvailableException()
|
||||||
|
elif error_number == 8:
|
||||||
|
raise NoRealtimeFileException()
|
||||||
|
elif error_number == 9:
|
||||||
|
raise FinishedReadingClosedFileException()
|
||||||
|
else:
|
||||||
|
"""Writer has returned a packet"""
|
||||||
|
if (response.packet_id != request.packet_id
|
||||||
|
or response.sequence_number != request.sequence_number):
|
||||||
|
raise ProtocolViolationException()
|
||||||
|
return response
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self._reset_state()
|
|
||||||
|
class ReadState(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.realtime = False # Not realtime until we get a 0-length response
|
||||||
|
self.realtime_file_open = False # We are reading from a file
|
||||||
|
self.offset = 0 # File offset to read from
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.__init__()
|
||||||
|
|
||||||
|
state = ReadState()
|
||||||
|
|
||||||
while not self.finished.isSet():
|
while not self.finished.isSet():
|
||||||
try:
|
try:
|
||||||
response = self._machine.read(self._file_offset)
|
if not state.realtime_file_open:
|
||||||
|
# Open realtime file
|
||||||
|
self._send_receive(StenoPacket.make_open_request())
|
||||||
|
state.realtime_file_open = True
|
||||||
|
response = self._send_receive(
|
||||||
|
StenoPacket.make_read_request(file_offset=state.offset)
|
||||||
|
)
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
log.warning(u'Stenograph machine disconnected, reconnecting…')
|
log.warning(u'Stenograph machine disconnected, reconnecting…')
|
||||||
log.debug('Stenograph exception: %s', str(e))
|
log.debug('Stenograph exception: %s', e)
|
||||||
self._reset_state()
|
# User could start a new file while disconnected.
|
||||||
|
state.reset()
|
||||||
if self._reconnect():
|
if self._reconnect():
|
||||||
log.warning('Stenograph reconnected.')
|
log.warning('Stenograph reconnected.')
|
||||||
self._ready()
|
self._ready()
|
||||||
except EOFError:
|
except NoRealtimeFileException:
|
||||||
# File ended -- will resume normal operation after new file
|
# User hasn't started writing, just keep opening the realtime file
|
||||||
self._reset_state()
|
state.reset()
|
||||||
|
except FinishedReadingClosedFileException:
|
||||||
|
# File closed! Open the realtime file.
|
||||||
|
state.reset()
|
||||||
else:
|
else:
|
||||||
if response is None:
|
if response.data_length:
|
||||||
continue
|
state.offset += response.data_length
|
||||||
if not self._read_exactly_8 and len(response) == 8:
|
elif not state.realtime:
|
||||||
self._read_exactly_8 = True
|
state.realtime = True
|
||||||
content = len(response) > 0
|
if response.data_length and state.realtime:
|
||||||
self._file_offset += len(response)
|
for stroke in response.strokes():
|
||||||
if not self._realtime and not content:
|
self._on_stroke(stroke)
|
||||||
self._realtime = True
|
|
||||||
elif self._realtime and content and self._read_exactly_8:
|
|
||||||
chords = Stenograph.process_steno_packet(response)
|
|
||||||
for keys in chords:
|
|
||||||
if keys:
|
|
||||||
self._on_stroke(keys)
|
|
||||||
self._machine.disconnect()
|
self._machine.disconnect()
|
||||||
|
|
||||||
def stop_capture(self):
|
def stop_capture(self):
|
||||||
"""Stop listening for output from the stenotype machine."""
|
"""Stop listening for output from the stenotype machine."""
|
||||||
super(Stenograph, self).stop_capture()
|
super(Stenograph, self).stop_capture()
|
||||||
self._machine = None
|
self._machine = None
|
||||||
self._stopped()
|
self._stopped()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def process_steno_packet(steno):
|
|
||||||
# Expecting 8 byte chords.
|
|
||||||
# Bytes 0-3 are steno, 4-7 are timestamp.
|
|
||||||
chords = []
|
|
||||||
for chord_index in range(len(steno) // 8):
|
|
||||||
keys = []
|
|
||||||
chord = steno[chord_index * 8: chord_index * 8 + 4]
|
|
||||||
for byte_number, byte in enumerate(chord):
|
|
||||||
if byte is None:
|
|
||||||
continue
|
|
||||||
byte_keys = STENO_KEY_CHART[byte_number]
|
|
||||||
for i in range(6):
|
|
||||||
if (byte >> i) & 1:
|
|
||||||
key = byte_keys[-i + 5]
|
|
||||||
if key:
|
|
||||||
keys.append(key)
|
|
||||||
if keys:
|
|
||||||
chords.append(keys)
|
|
||||||
return chords
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print(Stenograph.process_steno_packet(
|
|
||||||
[255, 255, 255, 255, 0, 0, 0, 0,
|
|
||||||
0, 0, 0, 0, 0, 0, 0, 0,
|
|
||||||
None, None, None, None, None, None, None, None,
|
|
||||||
0b11000000, 0b11000000, 0b11000000, 0b11000000, 255, 255, 255, 255,
|
|
||||||
0b11000001, 0b11000000, 0b11000000, 0b11000000, 255, 255, 255, 255,
|
|
||||||
0b11000000, 0b11100001, 0b11000000, 0b11000000, 255, 255, 255, 255,
|
|
||||||
0b11000001, 0b11000000, 0b11100001, 0b11000000, 255, 255, 255, 255,
|
|
||||||
0b11100000, 0b11000000, 0b11000000, 0b11100001, 255, 255, 255, 255,
|
|
||||||
]
|
|
||||||
))
|
|
||||||
|
|
@ -25,6 +25,8 @@ tests_require =
|
||||||
mock
|
mock
|
||||||
install_requires =
|
install_requires =
|
||||||
plover>=4.0.0.dev6
|
plover>=4.0.0.dev6
|
||||||
|
more_itertools
|
||||||
|
pyusb
|
||||||
py_modules =
|
py_modules =
|
||||||
plover_stenograph_usb
|
plover_stenograph_usb
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue