Framework-LED-Matrix 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,406 @@
1
+ import subprocess
2
+ import re
3
+ import serial
4
+ import serial.tools.list_ports
5
+ from typing import List, Optional, Tuple
6
+ import sys
7
+
8
+ WIDTH = 9
9
+ HEIGHT = 34
10
+ verbose = True
11
+ linux = bool(sys.platform == "linux")
12
+
13
+ class VerboseLogger:
14
+ def __init__(self, verbose=False):
15
+ self.verbose = verbose
16
+ def __call__(self, *args, **kwargs):
17
+ if self.verbose:
18
+ print(*args, **kwargs)
19
+
20
+ def find_matching_ports_windows(target_description):
21
+ """Finds COM ports with descriptions containing the target_description."""
22
+ matching_ports = {
23
+ 'left': '',
24
+ 'right': ''
25
+ }
26
+ for port in serial.tools.list_ports.comports():
27
+ if target_description in port.description:
28
+ # Check the device name, not the description
29
+ if port.device == 'COM3':
30
+ matching_ports['left'] = port.device
31
+ elif port.device == 'COM4':
32
+ matching_ports['right'] = port.device
33
+ return matching_ports
34
+
35
+ log = VerboseLogger()
36
+ log.verbose = verbose
37
+
38
+ modules = {'left': None, 'right': None}
39
+
40
+ # --- HARDWARE CONSTANTS ---
41
+ RIGHT_PCI_PATH_WINDOWS = ''
42
+ LEFT_PCI_PATH_WINDOWS = ''
43
+ RIGHT_PCI_PATH_LINUX = 'pci-0000:c2:00.3-usb-0:3.3:1.0'
44
+ LEFT_PCI_PATH_LINUX = 'pci-0000:c2:00.3-usb-0:4.2:1.0'
45
+ RIGHT_PCI_PATH = RIGHT_PCI_PATH_LINUX if linux else RIGHT_PCI_PATH_WINDOWS
46
+ LEFT_PCI_PATH = LEFT_PCI_PATH_LINUX if linux else LEFT_PCI_PATH_WINDOWS
47
+
48
+ DEFAULT_FONT_PATH = "/usr/share/fonts/TTF/DejaVuSansMono.ttf"
49
+
50
+ # --- COMMAND CONSTANTS ---
51
+ COMMANDS = {
52
+ "brightness": 0x00, "pattern": 0x01, "bootloader": 0x02, "sleep": 0x03,
53
+ "animate": 0x04, "panic": 0x05, "drawbw": 0x06, "stagecol": 0x07,
54
+ "flushcols": 0x08, "startgame": 0x10, "gamecontrol": 0x11,
55
+ "getsleep": 0x03, "getanimate": 0x04, "gamestatus": 0x12, "version": 0x20
56
+ }
57
+ PATTERNS = {
58
+ "percentage": [0x00], "gradient": [0x01], "doublegradient": [0x02],
59
+ "lotush": [0x03], "zigzag": [0x04], "full": [0x05], "panic": [0x06], "lotusv": [0x07]
60
+ }
61
+ GAMES = { "snake": [0], "pong": [1] }
62
+ GAME_CONTROLS = {
63
+ "pong": {
64
+ "far_player": {"left": [2], "right": [3]},
65
+ "close_player": {"left": [5], "right": [6]}, "stop": [4]
66
+ },
67
+ "snake": {
68
+ "up": [0], "down": [1], "left": [2], "right": [3], "stop": [4]
69
+ },
70
+ }
71
+
72
+
73
+ def get_module_paths():
74
+ """
75
+ Finds the stable /dev/ttyACM* paths for the left and right LED matrix modules
76
+ by matching their known physical PCI paths.
77
+
78
+ Returns:
79
+ dict: A dictionary mapping 'left' and 'right' to their /dev/ file paths.
80
+ e.g., {'left': '/dev/ttyACM1', 'right': '/dev/ttyACM0'}
81
+ Values will be None if a path is not found.
82
+ """
83
+ if not linux:
84
+ if modules['left'] is not None and modules['right'] is not None:
85
+ return modules
86
+ else:
87
+ found_ports = find_matching_ports_windows("USB Serial Device")
88
+ modules['left'] = found_ports['left']
89
+ modules['right'] = found_ports['right']
90
+ log(f"get_module_paths: found ports -> {modules}")
91
+ return modules
92
+
93
+ if modules['left'] is not None and modules['right'] is not None:
94
+ log("get_module_paths: using cached module paths", modules)
95
+ return modules
96
+
97
+ try:
98
+ result = subprocess.run(
99
+ ['ls', '-l', '/dev/serial/by-path/'],
100
+ capture_output=True,
101
+ text=True,
102
+ check=True
103
+ )
104
+ log("get_module_paths: ls output captured")
105
+ lines = result.stdout.strip().split('\n')
106
+ for line in lines:
107
+ # Find the 'ttyACM*' device name in the line
108
+ match = re.search(r'ttyACM\d+', line)
109
+ if not match:
110
+ continue # Skip lines that aren't ttyACM devices
111
+ device_path = f"/dev/{match.group(0)}"
112
+ log(f"get_module_paths: found device {device_path} in line: {line.strip()}")
113
+ if RIGHT_PCI_PATH in line:
114
+ modules['right'] = device_path # type: ignore
115
+ log(f"get_module_paths: mapped RIGHT -> {device_path}")
116
+ elif LEFT_PCI_PATH in line:
117
+ modules['left'] = device_path # type: ignore
118
+ log(f"get_module_paths: mapped LEFT -> {device_path}")
119
+ except FileNotFoundError:
120
+ log("get_module_paths: Error: 'ls' command not found. Please ensure coreutils are installed.")
121
+ except subprocess.CalledProcessError as e:
122
+ log(f"get_module_paths: Error listing serial devices: {e.stderr}")
123
+ except Exception as e:
124
+ log(f"get_module_paths: unexpected error: {e}")
125
+ log(f"get_module_paths: result -> {modules}")
126
+ return modules
127
+
128
+ def create_matrix(matrix_data):
129
+ """
130
+ Converts a 2D matrix (34 rows, 9 cols) into a 39-byte payload.
131
+
132
+ Args:
133
+ matrix_data (list[list[int]]): 2D array of 34x9. 1 = ON, 0 = OFF.
134
+ """
135
+ log(f"create_matrix: building payload")
136
+
137
+ # 1. Initialize the 39-byte payload with all zeros (LEDs off)
138
+ vals = [0] * 39
139
+
140
+ # 2. Pack the 2D matrix data into the 39-byte list
141
+ pixels_set = 0
142
+ for row in range(HEIGHT):
143
+ for col in range(WIDTH):
144
+
145
+ # Check the cell value.
146
+ # We assume 1 is ON, 0 is OFF.
147
+ cell = matrix_data[row][col]
148
+ if cell == 1:
149
+ # Convert [row][col] to 1D index
150
+ i = col + row * WIDTH
151
+
152
+ # Find the byte index (i // 8)
153
+ # Find the bit index (i % 8)
154
+ # Set the corresponding bit to 1
155
+ vals[i // 8] |= (1 << (i % 8))
156
+ pixels_set += 1
157
+ log(f"draw_matrix: packed {pixels_set} pixels into payload")
158
+ return vals
159
+
160
+ def draw_matrix_on_board(matrix_data, which='both'):
161
+ send_command(COMMANDS['drawbw'], create_matrix(matrix_data), which=which)
162
+
163
+
164
+ def create_greyscale_payloads(matrix_data: List[List[int]]) -> List[List[int]]:
165
+ """
166
+ Prepares the 9 separate column-payloads for the 'stagecol' command.
167
+
168
+ Args:
169
+ matrix_data (list[list[int]]): 2D array of 34x9 with brightness 0-255.
170
+
171
+ Returns:
172
+ list[list[int]]: A list of 9 payloads. Each payload is
173
+ [col_index] + [34 bytes of brightness].
174
+ """
175
+ all_payloads = []
176
+ log("create_greyscale_payloads: preparing 9 column payloads")
177
+
178
+ # Iterate by COLUMN (0 to 8)
179
+ for col in range(WIDTH):
180
+ column_brightness_data = []
181
+ for row in range(HEIGHT):
182
+ # Get brightness, ensure it's an int and clamp (0-255)
183
+ brightness = matrix_data[row][col]
184
+ brightness = max(0, min(255, int(brightness)))
185
+ column_brightness_data.append(brightness)
186
+
187
+ # Create the final parameters: [col_index] + [34 bytes]
188
+ parameters = [col] + column_brightness_data
189
+ all_payloads.append(parameters)
190
+
191
+ log(f"create_greyscale_payloads: created {len(all_payloads)} payloads.")
192
+ return all_payloads
193
+
194
+ def draw_greyscale_on_board(matrix_data: List[List[int]], which: str = 'both'):
195
+ """
196
+ Creates and draws a greyscale matrix on the board.
197
+ This version does NOT track global state.
198
+
199
+ Args:
200
+ matrix_data (list[list[int]]): 2D array of 34x9 (brightness 0-255).
201
+ which (str): 'left', 'right', 'both'.
202
+ """
203
+ log(f"draw_greyscale_on_board: starting (which={which})")
204
+
205
+ # 1. Create the payloads
206
+ all_column_payloads = create_greyscale_payloads(matrix_data)
207
+
208
+ # 2. Send all the staged column data
209
+ for payload in all_column_payloads:
210
+ send_command(COMMANDS['stagecol'], payload, which)
211
+
212
+ # 3. After all columns are staged, flush them to the display
213
+ send_command(COMMANDS['flushcols'], [], which)
214
+ log("draw_greyscale_on_board: flushed staged columns")
215
+
216
+ def set_led(matrix, row, col, brightness):
217
+ """
218
+ Helper function to safely set a pixel's brightness in a matrix.
219
+ This function DOES NOT send any commands.
220
+ """
221
+ if 0 <= row < HEIGHT and 0 <= col < WIDTH:
222
+ matrix[row][col] = int(max(0, min(255, int(brightness))))
223
+ log(f"set_led: set ({row},{col}) -> {matrix[row][col]}")
224
+ else:
225
+ log(f"set_led: Warning: Pixel ({row}, {col}) is out of bounds.")
226
+ print(f"Warning: Pixel ({row}, {col}) is out of bounds.")
227
+
228
+ def send_command(command_id, parameters, which='both', with_response=False):
229
+ log(f"send_command: enter command_id={command_id} which={which} with_response={with_response}")
230
+ if which == 'both' and with_response:
231
+ log("send_command: Error - cannot request response from both modules")
232
+ print("Error: Cannot request response from 'both' modules simultaneously.")
233
+ print("Please call separately for 'left' and 'right' if responses are needed.")
234
+ return None
235
+
236
+ modules = get_module_paths()
237
+ paths_to_send = []
238
+ if which == 'left':
239
+ paths_to_send.append(modules.get('left'))
240
+ elif which == 'right':
241
+ paths_to_send.append(modules.get('right'))
242
+ elif which == 'both':
243
+ paths_to_send.append(modules.get('left'))
244
+ paths_to_send.append(modules.get('right'))
245
+ else:
246
+ log(f"send_command: Error - invalid which parameter '{which}'")
247
+ print(f"Error: 'which' can only be 'left', 'right', or 'both', not '{which}'")
248
+ return None
249
+
250
+ # log(f"send_command: resolved paths -> {paths_to_send}")
251
+ response_data = None
252
+ for path in paths_to_send:
253
+ if path is None:
254
+ module_name = "unknown"
255
+ if path == modules.get('left'): module_name = 'left'
256
+ if path == modules.get('right'): module_name = 'right'
257
+ log(f"send_command: Error - Path for '{module_name}' module not found. Skipping.")
258
+ print(f"Error: Path for '{module_name}' module not found. Skipping.")
259
+ continue
260
+
261
+ try:
262
+ log(f"send_command: opening serial {path} at 115200")
263
+ with serial.Serial(path, 115200, timeout=1.0) as s:
264
+
265
+ payload = [0x32, 0xAC, command_id] + (parameters or [])
266
+ log(f"send_command: writing payload to {path}: {payload[:16]}{'...' if len(payload)>16 else ''}")
267
+
268
+ s.write(bytes(payload))
269
+ s.flush()
270
+
271
+ if with_response:
272
+ response_data = s.read(3) # Read 3 bytes, not 32
273
+ log(f"send_command: received response from {path}: {response_data}")
274
+ else:
275
+ log(f"send_command: write completed to {path} (no response requested)")
276
+
277
+ except serial.SerialException as e:
278
+ log(f"send_command: SerialException for {path}: {e}")
279
+ print(f"Error connecting to {path}: {e}")
280
+ except Exception as e:
281
+ log(f"send_command: unexpected error for {path}: {e}")
282
+ print(f"An unexpected error occurred with {path}: {e}")
283
+
284
+ log("send_command: exiting")
285
+ return response_data
286
+
287
+ def coordinates_to_matrix(coordinates: List[Tuple[int, int]] | List[List[int]]) -> List[List[int]]:
288
+ """
289
+ Translates a list of (row, col) tuples into a full 34x9 2D matrix.
290
+
291
+ Args:
292
+ coordinates: A list of (row, col) tuples to mark as '1'.
293
+
294
+ Returns:
295
+ A 34x9 2D matrix (List[List[int]]).
296
+ """
297
+ # 1. Create the initial board (all 0s)
298
+ matrix = [[0 for _ in range(WIDTH)] for _ in range(HEIGHT)]
299
+
300
+ # 2. Seed the board with the coordinates
301
+ pixels_seeded = 0
302
+ for r, c in coordinates:
303
+ if 0 <= r < HEIGHT and 0 <= c < WIDTH:
304
+ matrix[r][c] = 1 # 1 = live
305
+ pixels_seeded += 1
306
+
307
+ return matrix
308
+
309
+
310
+ def parse_version_string(response_bytes: Optional[bytes]) -> str:
311
+ """Helper to parse the 3-byte version response."""
312
+ log("parse_version_string: parsing response")
313
+ if not response_bytes or len(response_bytes) < 3:
314
+ log("parse_version_string: invalid or missing response")
315
+ return "v?.?.? (Error: No response)"
316
+
317
+ try:
318
+ major = response_bytes[0]
319
+ lsb = response_bytes[1]
320
+ pre_release_flag = response_bytes[2]
321
+
322
+ # LSB is mmmmPPPP
323
+ minor = (lsb & 0xF0) >> 4 # Get top 4 bits
324
+ patch = lsb & 0x0F # Get bottom 4 bits
325
+
326
+ pre_release_str = "-pre" if pre_release_flag == 1 else ""
327
+ version = f"v{major}.{minor}.{patch}{pre_release_str}"
328
+ log(f"parse_version_string: parsed version {version}")
329
+ return version
330
+
331
+ except Exception as e:
332
+ log(f"parse_version_string: error parsing response: {e}")
333
+ return f"v?.?.? (Error: {e})"
334
+
335
+ def get_firmware_version():
336
+ """
337
+ Gets, parses, and prints the firmware version for both modules.
338
+ """
339
+ log("get_firmware_version: querying modules for version")
340
+ print("Querying modules for version...")
341
+
342
+ right_response = send_command(
343
+ COMMANDS["version"],
344
+ parameters=None,
345
+ which='right',
346
+ with_response=True
347
+ )
348
+
349
+ left_response = send_command(
350
+ COMMANDS["version"],
351
+ parameters=None,
352
+ which='left',
353
+ with_response=True
354
+ )
355
+
356
+ right_parsed = parse_version_string(right_response)
357
+ left_parsed = parse_version_string(left_response)
358
+ log(f"get_firmware_version: right={right_parsed} left={left_parsed}")
359
+ print(f"Right LED Module: {right_parsed}")
360
+ print(f"Left LED Module: {left_parsed}")
361
+
362
+
363
+ def clear_graph():
364
+ """
365
+ Clears the LED matrix.
366
+ """
367
+ log("clear_graph: clearing matrix")
368
+ draw_matrix_on_board([[0] * WIDTH for _ in range(HEIGHT)], 'both')
369
+
370
+ def fill_graph():
371
+ """
372
+ Fills the LED matrix.
373
+ """
374
+ log("fill_graph: filling matrix")
375
+ draw_matrix_on_board([[1] * WIDTH for _ in range(HEIGHT)], 'both')
376
+
377
+ def start_animation():
378
+ """Starts the LED animation."""
379
+ log("start_animation: starting animation on both modules")
380
+ send_command(COMMANDS["animate"], [1], 'both')
381
+
382
+ def stop_animation():
383
+ """Stops the LED animation."""
384
+ log("stop_animation: stopping animation on both modules")
385
+ send_command(COMMANDS["animate"], [0], 'both')
386
+
387
+ def reset_modules():
388
+ """Resets both LED matrix modules."""
389
+ log("reset_modules: resetting modules (clear + stop animation)")
390
+ clear_graph()
391
+ stop_animation()
392
+
393
+ def output_ports():
394
+ """
395
+ Outputs the current module paths for debugging.
396
+ """
397
+ for port in serial.tools.list_ports.comports():
398
+ print(f"Device: {port.device}")
399
+ print(f" Name: {port.name}")
400
+ print(f" Description: {port.description}")
401
+ print(f" HWID: {port.hwid}")
402
+ print(f" VID: {port.vid}")
403
+ print(f" PID: {port.pid}")
404
+
405
+
406
+