Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create python wrapper for CC Serial API #69

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pynput~=1.7.6
pyinstaller~=5.13
setuptools~=68.1
PySide6~=6.5
pySerial~=3.5
234 changes: 234 additions & 0 deletions src/nexus/CCSerial/CCSerial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
from serial import Serial, SerialException
from serial.tools import list_ports
from serial.tools.list_ports_common import ListPortInfo


class CCSerial:

@staticmethod
def list_devices() -> list[ListPortInfo]:
"""
List CharaChorder serial devices
:returns: List of CharaChorder serial devices
"""
return list(filter(lambda p: p.manufacturer == "CharaChorder", list_ports.comports()))

def __init__(self, device: str) -> None:
"""
Initialize CharaChorder serial device
:param device: Path to device (use CCSerial.get_devices()[<device_idx>][0])
"""
try:
self.ser = Serial(device, 115200, timeout=1)
except SerialException:
self.close()
raise

def close(self):
"""
Close serial connection, must be called after completion of all serial operations on one device
"""
self.ser.close()

def _readline_to_list(self) -> list[str]:
"""
Read a line from the serial device and split it into a list
:return: List of strings if read was successful, empty list otherwise
"""
res = self.ser.readline().decode("utf-8")
return res.strip().split(" ") if res[-1] == "\n" else []

def get_device_id(self) -> str:
"""
Get CharaChorder device ID
:raises IOError: If serial response is invalid
:returns: Device ID
"""
try:
self.ser.write(b"ID\r\n")
res = None
while not res or len(res) == 1: # Drop serial output from chording during this time
res = self._readline_to_list()
except Exception:
self.close()
raise
if len(res) != 2 or res[0] != "ID":
raise IOError(f"Invalid response: {res}")
return res[1]

def get_device_version(self) -> str:
"""
Get CharaChorder device version
:raises IOError: If serial response is invalid
:returns: Device version
"""
try:
self.ser.write(b"VERSION\r\n")
res = None
while not res or len(res) == 1: # Drop serial output from chording during this time
res = self._readline_to_list()
except Exception:
self.close()
raise
if len(res) != 2 or res[0] != "VERSION":
raise IOError(f"Invalid response: {res}")
return res[1]

def get_chordmap_count(self) -> int:
"""
Get CharaChorder device chordmap count
:raises IOError: If serial response is invalid
:returns: Chordmap count
"""
try:
self.ser.write(b"CML C0\r\n")
res = None
while not res or len(res) == 1: # Drop serial output from chording during this time
res = self._readline_to_list()
except Exception:
self.close()
raise
if len(res) != 3 or res[0] != "CML" or res[1] != "C0":
raise IOError(f"Invalid response: {res}")
return int(res[2])

def get_chordmap_by_index(self, index: int) -> (str, str):
"""
Get chordmap from CharaChorder device by index
:param index: Chordmap index
:raises ValueError: If index is out of range
:raises IOError: If serial response is invalid
:returns: Chord (hex), Chordmap (Hexadecimal CCActionCodes List)
"""
if index < 0 or index >= self.get_chordmap_count():
raise ValueError("Index out of range")
try:
self.ser.write(f"CML C1 {index}\r\n".encode("utf-8"))
res = None
while not res or len(res) == 1: # Drop serial output from chording during this time
res = self._readline_to_list()
except Exception:
self.close()
raise
if len(res) != 6 or res[0] != "CML" or res[1] != "C1" or res[2] != str(index) or res[3] == "0" or res[4] == "0":
raise IOError(f"Invalid response: {res}")
return res[3], res[4]

def get_chordmap_by_chord(self, chord: str) -> str | None:
"""
Get chordmap from CharaChorder device by chord
:param chord: Chord (hex)
:raises ValueError: If chord is not a hex string
:raises IOError: If serial response is invalid
:returns: Chordmap (Hexadecimal CCActionCodes List), or None if chord was not found on device
"""
try:
int(chord, 16)
except ValueError:
raise ValueError("Chord must be a hex string")
try:
self.ser.write(f"CML C2 {chord}\r\n".encode("utf-8"))
res = None
while not res or len(res) == 1: # Drop serial output from chording during this time
res = self._readline_to_list()
except Exception:
self.close()
raise
if len(res) != 4 or res[0] != "CML" or res[1] != "C2" or res[2] != chord:
raise IOError(f"Invalid response: {res}")
return res[3] if res[3] != "0" else None

def set_chordmap_by_chord(self, chord: str, chordmap: str) -> bool:
"""
Set chordmap on CharaChorder device by chord
:param chord: Chord (hex)
:param chordmap: Chordmap (Hexadecimal CCActionCodes List)
:raises ValueError: If chord or chordmap is not a hex string
:raises IOError: If serial response is invalid
:returns: Whether the chord was set successfully
"""
try:
int(chord, 16)
except ValueError:
raise ValueError("Chord must be a hex string")
try:
int(chordmap, 16)
except ValueError:
raise ValueError("Chordmap must be a hex string")
try:
self.ser.write(f"CML C3 {chord}\r\n".encode("utf-8"))
res = None
while not res or len(res) == 1: # Drop serial output from chording during this time
res = self._readline_to_list()
except Exception:
self.close()
raise
if len(res) != 5 or res[0] != "CML" or res[1] != "C3" or res[2] != chord:
raise IOError(f"Invalid response: {res}")
return res[4] == "0"

def del_chordmap_by_chord(self, chord: str) -> bool:
"""
Delete chordmap from CharaChorder device by chord
:param chord: Chord (hex)
:raises ValueError: If chord is not a hex string
:raises IOError: If serial response is invalid
:returns: False if the chord was not found on the device or was not deleted, True otherwise
"""
try:
int(chord, 16)
except ValueError:
raise ValueError("Chord must be a hex string")
try:
self.ser.write(f"CML C4 {chord}\r\n".encode("utf-8"))
res = None
while not res or len(res) == 1: # Drop serial output from chording during this time
res = self._readline_to_list()
except Exception:
self.close()
raise
if len(res) != 4 or res[0] != "CML" or res[1] != "C4":
raise IOError(f"Invalid response: {res}")
return res[3] == "0"

@staticmethod
def decode_ascii_cc_action_code(code: int) -> str:
"""
Decode CharaChorder action code
:param code: integer action code
:return: character corresponding to decoded action code
:note: only decodes ASCII characters for now (32-126)
"""
if 32 <= code <= 126:
return chr(code)
else:
raise NotImplementedError(f"Action code {code} ({hex(code)}) not supported yet")

def list_device_chords(self) -> list[str]:
"""
List all chord(map)s on CharaChorder device
:return: list of chordmaps
"""
num_chords = self.get_chordmap_count()
chordmaps = []
for i in range(num_chords):
chord_hex = self.get_chordmap_by_index(i)[1]
chord_int = [int(chord_hex[i:i + 2], 16) for i in range(0, len(chord_hex), 2)]
chord_utf8 = []
for j, c in enumerate(chord_int):
if c < 32: # 10-bit scan code
chord_int[j + 1] = (chord_int[j] << 8) | chord_int[j + 1]
elif c == 296: # enter
chord_utf8.append("\n")
elif c == 298 and len(chord_utf8) > 0: # backspace
chord_utf8.pop()
elif c == 299: # tab
chord_utf8.append("\t")
elif c == 544: # spaceright
chord_utf8.append(" ")
elif c > 126: # TODO: support non-ASCII characters
continue
else:
chord_utf8.append(chr(c))
chordmaps.append("".join(chord_utf8).strip())
return chordmaps
5 changes: 5 additions & 0 deletions src/nexus/CCSerial/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""nexus module Freqlog: frequency logging for words and chords."""

__all__ = ["CCSerial"]

from .CCSerial import CCSerial
5 changes: 3 additions & 2 deletions src/nexus/Freqlog/Definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@

class Defaults:
# Allowed keys in chord output: a-z, A-Z, 0-9, apostrophe, dash, underscore, slash, backslash, tilde
DEFAULT_ALLOWED_KEYS_IN_CHORD: set = {chr(i) for i in range(97, 123)} | {chr(i) for i in range(65, 91)} | \
{chr(i) for i in range(48, 58)} | {"'", "-", "_", "/", "\\", "~"}
DEFAULT_ALLOWED_KEYS_IN_CHORD: set = \
{chr(i) for i in range(ord('a'), ord('z') + 1)} | {chr(i) for i in range(ord('A'), ord('Z') + 1)} | \
{chr(i) for i in range(ord('0'), ord('9') + 1)} | {"'", "-", "_", "/", "\\", "~"}
DEFAULT_MODIFIER_KEYS: set = {Key.ctrl, Key.ctrl_l, Key.ctrl_r, Key.alt, Key.alt_l, Key.alt_r, Key.alt_gr, Key.cmd,
Key.cmd_l, Key.cmd_r}
DEFAULT_NEW_WORD_THRESHOLD: float = 5 # seconds after which character input is considered a new word
Expand Down
16 changes: 14 additions & 2 deletions src/nexus/Freqlog/Freqlog.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .backends import Backend, SQLiteBackend
from .Definitions import ActionType, BanlistAttr, BanlistEntry, CaseSensitivity, ChordMetadata, ChordMetadataAttr, \
Defaults, WordMetadata, WordMetadataAttr
from ..CCSerial import CCSerial


class Freqlog:
Expand Down Expand Up @@ -339,8 +340,8 @@ def export_words_to_csv(self, export_path: str, limit: int = -1,
logging.info(f"Exported {len(words)} words to {export_path}")
return len(words)

def list_chords(self, limit: int, sort_by: ChordMetadataAttr, reverse: bool,
case: CaseSensitivity) -> list[ChordMetadata]:
def list_logged_chords(self, limit: int, sort_by: ChordMetadataAttr, reverse: bool,
case: CaseSensitivity) -> list[ChordMetadata]:
"""
List chords in the store
:param limit: Maximum number of chords to return
Expand Down Expand Up @@ -370,6 +371,17 @@ def export_chords_to_csv(self, export_path: str, limit: int, sort_by: ChordMetad
logging.info(f"Exported {len(chords)} chords to {export_path}")
return len(chords)

@staticmethod
def list_device_chords() -> list[str]:
"""
List chords in the store
:return: list of chords
"""
dev = CCSerial(CCSerial.get_devices()[0][0])
chords = dev.list_device_chords()
dev.close()
return chords

def list_banned_words(self, limit: int = -1, sort_by: BanlistAttr = BanlistAttr.word,
reverse: bool = False) -> tuple[set[BanlistEntry], set[BanlistEntry]]:
"""
Expand Down
6 changes: 3 additions & 3 deletions src/nexus/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def main():
# parser_chords.add_argument("-e", "--export", help="Export freqlogged chords as csv to file"
# "(ignores chord args)", required=False)
# parser_chords.add_argument("-s", "--sort-by", default=ChordMetadataAttr.frequency.name,
# help=f"Sort by (default: {ChordMetadataAttr.frequency.name})")
# help=f"Sort by (default: {ChordMetadataAttr.frequency.name})"),
# choices=[attr.name for attr in ChordMetadataAttr])
# parser_chords.add_argument("-o", "--order", default=Order.ASCENDING, help="Order (default: DESCENDING)",
# choices=[order.name for order in Order])
Expand Down Expand Up @@ -279,8 +279,8 @@ def main():
freqlog.export_chords_to_csv(args.export, num, ChordMetadataAttr[args.sort_by],
args.order == Order.DESCENDING, CaseSensitivity[args.case])
elif len(args.chord) == 0: # all chords
res = freqlog.list_chords(num, ChordMetadataAttr[args.sort_by], args.order == Order.DESCENDING,
CaseSensitivity[args.case])
res = freqlog.list_logged_chords(num, ChordMetadataAttr[args.sort_by],
args.order == Order.DESCENDING)
if len(res) == 0:
print("No chords in freqlog. Start chording!")
else:
Expand Down