Skip to content

Commit

Permalink
Chord logging (#75)
Browse files Browse the repository at this point in the history
* Create python wrapper for CC Serial API

* Close serial port in case of fire

* Use OOP

* Update CCSerial.py

* Fix chording during loading chords

* Bugfix

* Implement chord logging

Version bump, lots of bugs rn

* Fix whitespace in log issue

* Bugfix

* Fix chord logging

* Fix check method

* More bug fixes

* Fix closing serial port

* Note bugs

* Fix version after rebase

* Fix chord export and cleanup main

- Remove NotImplementedError try-except

* Search/filter for chord table
  • Loading branch information
Raymo111 authored Oct 31, 2023
1 parent 908c3c2 commit bc993a8
Show file tree
Hide file tree
Showing 12 changed files with 1,087 additions and 359 deletions.
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
261 changes: 261 additions & 0 deletions src/nexus/CCSerial/CCSerial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
from dataclasses import dataclass
from typing import Iterator

from serial import Serial
from serial.tools import list_ports
from serial.tools.list_ports_common import ListPortInfo


@dataclass
class CCDevice:
"""
CharaChorder device
"""
name: str
device: ListPortInfo

def __repr__(self):
return self.name + " (" + self.device.device + ")"

def __str__(self):
return self.name + " (" + self.device.device + ")"


class CCSerial:

@staticmethod
def list_devices() -> list[CCDevice]:
"""
List CharaChorder serial devices
:returns: List of CharaChorder serial devices
"""
devices: list[CCDevice] = []
for dev in list_ports.comports():
match dev.vid:
case 9114: # Adafruit (M0)
match dev.pid:
case 32783:
devices.append(CCDevice("CharaChorder One", dev))
case 32796:
devices.append(CCDevice("CharaChorder Lite (M0)", dev))
case 12346: # Espressif (S2)
match dev.pid:
case 33070:
devices.append(CCDevice("CharaChorder Lite (S2)", dev))
case 33163:
devices.append(CCDevice("CharaChorder X", dev))
return devices

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

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 and 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) -> Iterator[str]:
"""
List all chord(map)s on CharaChorder device
:return: list of chordmaps
"""
num_chords = self.get_chordmap_count()
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))
yield "".join(chord_utf8).strip()
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
56 changes: 40 additions & 16 deletions src/nexus/Freqlog/Definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@

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
DEFAULT_CHORD_CHAR_THRESHOLD: int = 30 # milliseconds between characters in a chord to be considered a chord
DEFAULT_CHORD_CHAR_THRESHOLD: int = 5 # milliseconds between characters in a chord to be considered a chord
DEFAULT_DB_PATH: str = "nexus_freqlog_db.sqlite3"
DEFAULT_NUM_WORDS_CLI: int = 10
DEFAULT_NUM_WORDS_GUI: int = 100
Expand Down Expand Up @@ -60,13 +61,10 @@ def __or__(self, other: Any) -> Self:
return self
if self.word != other.word:
raise ValueError(f"Cannot merge WordMetadata objects with different words: {self.word} and {other.word}")
return WordMetadata(
self.word,
self.frequency + other.frequency,
max(self.last_used, other.last_used),
(self.average_speed * self.frequency + other.average_speed * other.frequency) / (
self.frequency + other.frequency)
)
return WordMetadata(self.word, self.frequency + other.frequency,
max(self.last_used, other.last_used),
(self.average_speed * self.frequency + other.average_speed * other.frequency) / (
self.frequency + other.frequency))

def __str__(self) -> str:
return f"Word: {self.word} | Frequency: {self.frequency} | Last used: {self.last_used} | " \
Expand All @@ -85,11 +83,13 @@ class WordMetadataAttr(Enum):
score = "score"


WordMetadataAttrLabel = {WordMetadataAttr.word: "Word",
WordMetadataAttr.frequency: "Freq.",
WordMetadataAttr.last_used: "Last used",
WordMetadataAttr.average_speed: "Avg. speed",
WordMetadataAttr.score: "Score"}
WordMetadataAttrLabel = {
WordMetadataAttr.word: "Word",
WordMetadataAttr.frequency: "Freq.",
WordMetadataAttr.last_used: "Last used",
WordMetadataAttr.average_speed: "Avg. speed",
WordMetadataAttr.score: "Score"
}


class ChordMetadata:
Expand All @@ -99,16 +99,40 @@ def __init__(self, chord: str, frequency: int, last_used: datetime) -> None:
self.chord = chord
self.frequency = frequency
self.last_used = last_used
self.score = len(chord) * frequency

def __or__(self, other: Any) -> Self:
"""Merge two ChordMetadata objects"""
if other is not None and not isinstance(other, ChordMetadata):
raise TypeError(f"unsupported operand type(s) for |: '{type(self).__name__}' and '{type(other).__name__}'")
if other is None:
return self
if self.chord != other.chord:
raise ValueError(
f"Cannot merge ChordMetadata objects with different chords: {self.chord} and {other.chord}")
return ChordMetadata(self.chord, self.frequency + other.frequency, max(self.last_used, other.last_used))

def __str__(self) -> str:
return f"Chord: {self.chord} | Frequency: {self.frequency} | Last used: {self.last_used} | "
return f"Chord: {self.chord} | Frequency: {self.frequency} | Last used: {self.last_used}"

def __repr__(self) -> str:
return f"ChordMetadata({self.chord})"


class ChordMetadataAttr(Enum):
"""Enum for chord metadata attributes"""
chord = "chord"
frequency = "frequency"
last_used = "lastused"
score = "score"


ChordMetadataAttrLabel = {
ChordMetadataAttr.chord: "Chord",
ChordMetadataAttr.frequency: "Freq.",
ChordMetadataAttr.last_used: "Last used",
ChordMetadataAttr.score: "Score"
}


class BanlistEntry:
Expand Down
Loading

0 comments on commit bc993a8

Please sign in to comment.