ledmatrix 0.1.0__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.
ledmatrix/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Framework 16 LED Matrix SDK."""
2
+ from .async_device import AsyncDevice
3
+ from .canvas import Canvas
4
+ from .device import Device, DeviceDetails, DeviceInfo, list_devices, open_device
5
+ from .dither import dither
6
+ from .exceptions import (
7
+ DeviceDisconnected,
8
+ DeviceError,
9
+ DeviceNotFound,
10
+ DeviceStalled,
11
+ ImageDependencyError,
12
+ LedMatrixError,
13
+ ProtocolError,
14
+ TransportError,
15
+ TransportUnavailable,
16
+ UnsupportedCapability,
17
+ )
18
+ from .geometry import FW16_LED_MATRIX, MatrixGeometry, PackingOrder
19
+ from .font import Font, draw_text_scrolling
20
+ from .hotplug import DeviceWatcher
21
+ from .image import ImagePipeline
22
+ from .protocol import Command, DeviceCapabilities, FirmwareVersion, Pattern
23
+ from .scheduler import FrameScheduler
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "AsyncDevice", "Canvas", "Command", "Device", "DeviceCapabilities", "DeviceDetails", "DeviceDisconnected",
29
+ "DeviceError", "DeviceInfo", "DeviceNotFound", "DeviceStalled", "DeviceWatcher", "FW16_LED_MATRIX",
30
+ "FirmwareVersion", "Font", "FrameScheduler", "ImageDependencyError", "ImagePipeline", "LedMatrixError",
31
+ "MatrixGeometry", "PackingOrder", "Pattern", "ProtocolError", "TransportError", "TransportUnavailable",
32
+ "UnsupportedCapability", "__version__", "dither", "draw_text_scrolling", "list_devices", "open_device",
33
+ ]
@@ -0,0 +1,64 @@
1
+ """Async wrapper around :class:`ledmatrix.device.Device`."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Optional, Sequence, Union
6
+
7
+ from .canvas import Canvas
8
+ from .device import Device, DeviceDetails
9
+ from .protocol import FirmwareVersion, Pattern
10
+
11
+
12
+ class AsyncDevice:
13
+ """Run the synchronous serial API in worker threads without changing its semantics."""
14
+
15
+ def __init__(self, device: Device) -> None:
16
+ self.device = device
17
+
18
+ async def __aenter__(self) -> "AsyncDevice":
19
+ await asyncio.to_thread(self.device.__enter__)
20
+ return self
21
+
22
+ async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
23
+ await self.close()
24
+
25
+ async def close(self) -> None:
26
+ await asyncio.to_thread(self.device.close)
27
+
28
+ async def set_brightness(self, percent: int) -> None:
29
+ await asyncio.to_thread(self.device.set_brightness, percent)
30
+
31
+ async def get_brightness(self) -> int:
32
+ return await asyncio.to_thread(self.device.get_brightness)
33
+
34
+ async def set_pattern(self, pattern: Union[int, Pattern], value: Optional[int] = None) -> None:
35
+ await asyncio.to_thread(self.device.set_pattern, pattern, value)
36
+
37
+ async def sleep(self) -> None:
38
+ await asyncio.to_thread(self.device.sleep)
39
+
40
+ async def wake(self) -> None:
41
+ await asyncio.to_thread(self.device.wake)
42
+
43
+ async def is_awake(self) -> bool:
44
+ return await asyncio.to_thread(self.device.is_awake)
45
+
46
+ async def show_frame(self, frame: Union[Canvas, bytes, bytearray]) -> None:
47
+ await asyncio.to_thread(self.device.show_frame, frame)
48
+
49
+ async def schedule_frame(self, frame: Union[Canvas, bytes, bytearray]) -> None:
50
+ await asyncio.to_thread(self.device.schedule_frame, frame)
51
+
52
+ async def set_fps(self, fps: float) -> None:
53
+ await asyncio.to_thread(self.device.set_fps, fps)
54
+
55
+ async def get_firmware_version(self) -> FirmwareVersion:
56
+ return await asyncio.to_thread(self.device.get_firmware_version)
57
+
58
+ async def get_device_info(self) -> DeviceDetails:
59
+ return await asyncio.to_thread(self.device.get_device_info)
60
+
61
+ async def raw_command(
62
+ self, opcode: int, payload: Union[bytes, bytearray, Sequence[int]] = b"", response_bytes: int = 0
63
+ ) -> bytes:
64
+ return await asyncio.to_thread(self.device.raw_command, opcode, payload, response_bytes)
ledmatrix/canvas.py ADDED
@@ -0,0 +1,208 @@
1
+ """Packed 1-bit drawing surface for the Framework LED Matrix."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Iterable, Optional, Sequence, TYPE_CHECKING
5
+
6
+ from .geometry import FW16_LED_MATRIX, MatrixGeometry
7
+
8
+ if TYPE_CHECKING:
9
+ from .device import Device
10
+
11
+
12
+ class Canvas:
13
+ """A mutable 1-bit canvas backed by a packed :class:`bytearray`.
14
+
15
+ Coordinates are zero-based: ``x`` increases across the short edge and ``y`` down the
16
+ long edge. The default 9x34 geometry and column-major LSB packing match the current
17
+ upstream Framework protocol's DrawBW payload.
18
+ """
19
+
20
+ def __init__(self, geometry: MatrixGeometry = FW16_LED_MATRIX, data: Optional[bytes] = None) -> None:
21
+ self.geometry = geometry
22
+ if data is None:
23
+ self._data = bytearray(geometry.frame_bytes)
24
+ else:
25
+ if len(data) != geometry.frame_bytes:
26
+ raise ValueError(
27
+ "expected %d packed bytes, got %d" % (geometry.frame_bytes, len(data))
28
+ )
29
+ self._data = bytearray(data)
30
+
31
+ @property
32
+ def width(self) -> int:
33
+ return self.geometry.width
34
+
35
+ @property
36
+ def height(self) -> int:
37
+ return self.geometry.height
38
+
39
+ @property
40
+ def buffer(self) -> bytearray:
41
+ """The mutable packed framebuffer. Treat direct mutation as an advanced API."""
42
+ return self._data
43
+
44
+ def _address(self, x: int, y: int) -> tuple[int, int]:
45
+ index = self.geometry.bit_index(x, y)
46
+ return index // 8, index % 8
47
+
48
+ def set_pixel(self, x: int, y: int, state: Any = True) -> "Canvas":
49
+ byte_index, bit = self._address(x, y)
50
+ mask = 1 << bit
51
+ if bool(state):
52
+ self._data[byte_index] |= mask
53
+ else:
54
+ self._data[byte_index] &= ~mask & 0xFF
55
+ return self
56
+
57
+ def get_pixel(self, x: int, y: int) -> bool:
58
+ byte_index, bit = self._address(x, y)
59
+ return bool(self._data[byte_index] & (1 << bit))
60
+
61
+ def clear(self) -> "Canvas":
62
+ self._data[:] = b"\x00" * len(self._data)
63
+ return self
64
+
65
+ def fill(self, state: Any = True) -> "Canvas":
66
+ self._data[:] = (b"\xff" if bool(state) else b"\x00") * len(self._data)
67
+ # The last byte may contain unused bits. Clear them so frames are deterministic.
68
+ unused = len(self._data) * 8 - self.geometry.pixels
69
+ if bool(state) and unused:
70
+ self._data[-1] &= (1 << (8 - unused)) - 1
71
+ return self
72
+
73
+ def _clip_rect(self, x: int, y: int, width: int, height: int) -> tuple[int, int, int, int]:
74
+ if width < 0 or height < 0:
75
+ raise ValueError("rectangle width and height must be non-negative")
76
+ x0 = max(0, x)
77
+ y0 = max(0, y)
78
+ x1 = min(self.width, x + width)
79
+ y1 = min(self.height, y + height)
80
+ return x0, y0, x1, y1
81
+
82
+ def fill_rect(self, x: int, y: int, width: int, height: int, state: Any = True) -> "Canvas":
83
+ x0, y0, x1, y1 = self._clip_rect(x, y, width, height)
84
+ for xx in range(x0, x1):
85
+ for yy in range(y0, y1):
86
+ self.set_pixel(xx, yy, state)
87
+ return self
88
+
89
+ def clear_rect(self, x: int, y: int, width: int, height: int) -> "Canvas":
90
+ return self.fill_rect(x, y, width, height, False)
91
+
92
+ def draw_rect(
93
+ self, x: int, y: int, width: int, height: int, state: Any = True
94
+ ) -> "Canvas":
95
+ if width < 0 or height < 0:
96
+ raise ValueError("rectangle width/height must be non-negative")
97
+ if width == 0 or height == 0:
98
+ return self
99
+
100
+ x1 = x + width - 1
101
+ y1 = y + height - 1
102
+ self.draw_line(x, y, x1, y, state)
103
+ if height > 1:
104
+ self.draw_line(x, y1, x1, y1, state)
105
+ if height > 2:
106
+ self.draw_line(x, y + 1, x, y1 - 1, state)
107
+ if width > 1:
108
+ self.draw_line(x1, y + 1, x1, y1 - 1, state)
109
+ return self
110
+
111
+ def invert_rect(self, x: int, y: int, width: int, height: int) -> "Canvas":
112
+ x0, y0, x1, y1 = self._clip_rect(x, y, width, height)
113
+ for xx in range(x0, x1):
114
+ for yy in range(y0, y1):
115
+ byte_index, bit = self._address(xx, yy)
116
+ self._data[byte_index] ^= 1 << bit
117
+ return self
118
+
119
+ def draw_line(self, x0: int, y0: int, x1: int, y1: int, state: Any = True) -> "Canvas":
120
+ """Draw an inclusive Bresenham line; pixels outside the canvas are clipped."""
121
+ dx = abs(x1 - x0)
122
+ sx = 1 if x0 < x1 else -1
123
+ dy = -abs(y1 - y0)
124
+ sy = 1 if y0 < y1 else -1
125
+ error = dx + dy
126
+ while True:
127
+ if 0 <= x0 < self.width and 0 <= y0 < self.height:
128
+ self.set_pixel(x0, y0, state)
129
+ if x0 == x1 and y0 == y1:
130
+ break
131
+ twice_error = 2 * error
132
+ if twice_error >= dy:
133
+ error += dy
134
+ x0 += sx
135
+ if twice_error <= dx:
136
+ error += dx
137
+ y0 += sy
138
+ return self
139
+
140
+ def shift(self, dx: int, dy: int, fill: Any = False) -> "Canvas":
141
+ if dx == 0 and dy == 0:
142
+ return self
143
+
144
+ source = self.copy()
145
+ self.fill(fill)
146
+
147
+ for y in range(self.height):
148
+ src_y = y - dy
149
+ if not 0 <= src_y < self.height:
150
+ continue
151
+ for x in range(self.width):
152
+ src_x = x - dx
153
+ if 0 <= src_x < self.width:
154
+ self.set_pixel(x, y, source.get_pixel(src_x, src_y))
155
+ return self
156
+
157
+ def copy(self) -> "Canvas":
158
+ return Canvas(self.geometry, bytes(self._data))
159
+
160
+ clone = copy
161
+
162
+ def to_bytes(self) -> bytes:
163
+ """Return an immutable copy in the device's packed DrawBW wire format."""
164
+ return bytes(self._data)
165
+
166
+ def to_rows(self, on: str = "#", off: str = ".") -> list[str]:
167
+ return [
168
+ "".join(on if self.get_pixel(x, y) else off for x in range(self.width))
169
+ for y in range(self.height)
170
+ ]
171
+
172
+ def show(self, device: "Device") -> None:
173
+ device.show_frame(self)
174
+
175
+ @classmethod
176
+ def from_bytes(cls, data: bytes, geometry: MatrixGeometry = FW16_LED_MATRIX) -> "Canvas":
177
+ return cls(geometry=geometry, data=data)
178
+
179
+ @classmethod
180
+ def from_array(
181
+ cls, values: Sequence[Sequence[Any]], geometry: MatrixGeometry = FW16_LED_MATRIX, threshold: int = 0
182
+ ) -> "Canvas":
183
+ """Build a canvas from a row-major nested sequence or NumPy ``(height, width)`` array."""
184
+ if len(values) != geometry.height:
185
+ raise ValueError("expected %d rows, got %d" % (geometry.height, len(values)))
186
+ canvas = cls(geometry)
187
+ for y, row in enumerate(values):
188
+ if len(row) != geometry.width:
189
+ raise ValueError("expected %d columns in row %d, got %d" % (geometry.width, y, len(row)))
190
+ for x, value in enumerate(row):
191
+ canvas.set_pixel(x, y, bool(value) if isinstance(value, bool) else int(value) > threshold)
192
+ return canvas
193
+
194
+ @classmethod
195
+ def from_pil(
196
+ cls,
197
+ image: Any,
198
+ geometry: MatrixGeometry = FW16_LED_MATRIX,
199
+ dither: str = "threshold",
200
+ resize: str = "nearest",
201
+ threshold: int = 128,
202
+ ) -> "Canvas":
203
+ from .image import ImagePipeline
204
+
205
+ return ImagePipeline(geometry=geometry, dither=dither, resize=resize, threshold=threshold).process(image)
206
+
207
+ def __repr__(self) -> str:
208
+ return "Canvas(width=%d, height=%d, bytes=%r)" % (self.width, self.height, bytes(self._data))
@@ -0,0 +1 @@
1
+ """Command-line interface package."""
ledmatrix/cli/main.py ADDED
@@ -0,0 +1,250 @@
1
+ """Debugging CLI for Framework 16 LED Matrix modules."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from importlib import resources
7
+ from pathlib import Path
8
+ from typing import Optional, Sequence
9
+
10
+ from ..canvas import Canvas
11
+ from ..device import DeviceInfo, list_devices, open_device
12
+ from ..exceptions import LedMatrixError
13
+ from ..font import Font
14
+ from ..geometry import FW16_LED_MATRIX, MatrixGeometry
15
+ from ..image import ImagePipeline
16
+ from ..transport import FRAMEWORK_VID, LED_MATRIX_PID, MockTransport
17
+
18
+
19
+ def _int(value: str) -> int:
20
+ return int(value, 0)
21
+
22
+
23
+ def _hex(value: str) -> bytes:
24
+ try:
25
+ return bytes.fromhex(value)
26
+ except ValueError as exc:
27
+ raise argparse.ArgumentTypeError("payload must be hex bytes, e.g. 00 ff a1") from exc
28
+
29
+
30
+ def _add_target_arguments(parser: argparse.ArgumentParser) -> None:
31
+ parser.add_argument("--port", help="explicit serial port (for example COM3 or /dev/ttyACM0)")
32
+ parser.add_argument("--serial", help="Framework device serial number")
33
+ parser.add_argument("--dry-run", action="store_true", help="record commands without opening hardware")
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ parser = argparse.ArgumentParser(prog="ledmatrix", description="Framework 16 LED Matrix SDK CLI")
38
+ sub = parser.add_subparsers(dest="command", required=True)
39
+
40
+ sub.add_parser("list", help="list discovered LED Matrix serial devices")
41
+
42
+ info = sub.add_parser("info", help="show device metadata and firmware version")
43
+ _add_target_arguments(info)
44
+
45
+ brightness = sub.add_parser("brightness", help="set global brightness (0..100 percent)")
46
+ _add_target_arguments(brightness)
47
+ brightness.add_argument("value", type=int)
48
+
49
+ pixel = sub.add_parser("pixel", help="show a single pixel on a cleared frame")
50
+ _add_target_arguments(pixel)
51
+ pixel.add_argument("x", type=int)
52
+ pixel.add_argument("y", type=int)
53
+
54
+ rect = sub.add_parser("rect", help="show a filled rectangle on a cleared frame")
55
+ _add_target_arguments(rect)
56
+ rect.add_argument("x", type=int)
57
+ rect.add_argument("y", type=int)
58
+ rect.add_argument("width", type=int)
59
+ rect.add_argument("height", type=int)
60
+
61
+ clear = sub.add_parser("clear", help="clear the display")
62
+ _add_target_arguments(clear)
63
+
64
+ image = sub.add_parser("image", help="load, resize, dither, and display an image")
65
+ _add_target_arguments(image)
66
+ image.add_argument("path")
67
+ image.add_argument("--dither", default="threshold", choices=["none", "threshold", "bayer2x2", "bayer4x4", "floyd_steinberg"])
68
+ image.add_argument("--resize", default="nearest", choices=["nearest", "bilinear", "area"])
69
+
70
+ text = sub.add_parser("text", help="render text on a cleared frame")
71
+ _add_target_arguments(text)
72
+ text.add_argument("text")
73
+ text.add_argument("--font", default="5x7", choices=["tom-thumb", "4x6", "5x7", "3x5"])
74
+ text.add_argument("--x", type=int, default=0)
75
+ text.add_argument("--y", type=int, default=0)
76
+ text.add_argument("--rotate", type=int, default=0, choices=[0, 90, 180, 270])
77
+ text.add_argument("--preview", action="store_true", help="print the 9x34 software frame before sending")
78
+
79
+ orientation = sub.add_parser("orientation-test", help="show an asymmetric orientation diagnostic frame")
80
+ _add_target_arguments(orientation)
81
+ orientation.add_argument("--preview", action="store_true", help="print the 9x34 software frame before sending")
82
+
83
+ raw = sub.add_parser("raw", help="send a raw opcode and hex payload")
84
+ _add_target_arguments(raw)
85
+ raw.add_argument("opcode", type=_int)
86
+ raw.add_argument("payload", type=_hex, nargs="?", default=b"")
87
+ raw.add_argument("--response-bytes", type=int, default=0)
88
+
89
+ system = sub.add_parser("system", help="print or install the optional Linux udev rule")
90
+ system_sub = system.add_subparsers(dest="system_command", required=True)
91
+ system_sub.add_parser("udev-rule", help="print the packaged udev rule")
92
+ install = system_sub.add_parser("install-udev", help="copy the udev rule to a chosen writable path")
93
+ install.add_argument("--target", required=True, help="destination file; use sudo outside this tool if needed")
94
+
95
+ return parser
96
+
97
+
98
+ def _open(args: argparse.Namespace):
99
+ if args.dry_run:
100
+ mock = MockTransport(responses=[bytes((0, 0, 0))])
101
+ info = DeviceInfo(
102
+ path="mock://cli", vid=FRAMEWORK_VID, pid=LED_MATRIX_PID, serial="DRYRUN", product="LED_Matrix"
103
+ )
104
+ return open_device(transport=mock, info=info), mock
105
+ return open_device(port=args.port, serial=args.serial), None
106
+
107
+
108
+ def _print_dry_run(mock: Optional[MockTransport]) -> None:
109
+ if mock is None:
110
+ return
111
+ for index, write in enumerate(mock.writes, start=1):
112
+ print("dry-run write %d: %s" % (index, write.hex(" ")))
113
+
114
+
115
+ def _rule_text() -> str:
116
+ resource = resources.files("ledmatrix.data").joinpath("50-framework-inputmodule.rules")
117
+ return resource.read_text(encoding="utf-8")
118
+
119
+
120
+ def _print_preview(canvas: Canvas) -> None:
121
+ for row in canvas.to_rows():
122
+ print(row)
123
+
124
+
125
+ def _logical_canvas(rotation: int) -> Canvas:
126
+ if rotation in (90, 270):
127
+ return Canvas(MatrixGeometry(width=FW16_LED_MATRIX.height, height=FW16_LED_MATRIX.width))
128
+ return Canvas()
129
+
130
+
131
+ def _rotate_canvas(canvas: Canvas, rotation: int) -> Canvas:
132
+ rotation %= 360
133
+ if rotation == 0:
134
+ return canvas
135
+
136
+ output = Canvas()
137
+ if rotation == 90:
138
+ for y in range(canvas.height):
139
+ for x in range(canvas.width):
140
+ output.set_pixel(output.width - 1 - y, x, canvas.get_pixel(x, y))
141
+ elif rotation == 180:
142
+ for y in range(canvas.height):
143
+ for x in range(canvas.width):
144
+ output.set_pixel(output.width - 1 - x, output.height - 1 - y, canvas.get_pixel(x, y))
145
+ elif rotation == 270:
146
+ for y in range(canvas.height):
147
+ for x in range(canvas.width):
148
+ output.set_pixel(y, output.height - 1 - x, canvas.get_pixel(x, y))
149
+ else:
150
+ raise ValueError("rotation must be one of 0, 90, 180, or 270")
151
+ return output
152
+
153
+
154
+ def _orientation_test_canvas() -> Canvas:
155
+ canvas = Canvas()
156
+ canvas.draw_rect(0, 0, canvas.width, canvas.height)
157
+ canvas.draw_line(0, 0, canvas.width - 1, canvas.height - 1)
158
+ canvas.set_pixel(0, 0, True)
159
+ canvas.set_pixel(canvas.width - 1, 0, True)
160
+ canvas.set_pixel(0, canvas.height - 1, True)
161
+ canvas.fill_rect(canvas.width - 2, canvas.height - 3, 2, 3, True)
162
+ Font.load("3x5").draw_text(canvas, 1, 2, "HI")
163
+ return canvas
164
+
165
+
166
+ def run(args: argparse.Namespace) -> int:
167
+ try:
168
+ if args.command == "list":
169
+ devices = list_devices()
170
+ if not devices:
171
+ print("No Framework LED Matrix serial devices found.")
172
+ return 0
173
+ for index, info in enumerate(devices):
174
+ vid = "%04x" % info.vid if info.vid is not None else "????"
175
+ pid = "%04x" % info.pid if info.pid is not None else "????"
176
+ serial = info.serial or ""
177
+ print("%d: %s vid=%s pid=%s serial=%s" % (index, info.path, vid, pid, serial))
178
+ return 0
179
+
180
+ if args.command == "system":
181
+ if args.system_command == "udev-rule":
182
+ print(_rule_text(), end="")
183
+ return 0
184
+ if args.system_command == "install-udev":
185
+ Path(args.target).write_text(_rule_text(), encoding="utf-8")
186
+ print("installed udev rule to %s" % args.target)
187
+ return 0
188
+
189
+ device, mock = _open(args)
190
+ with device:
191
+ if args.command == "info":
192
+ details = device.get_device_info()
193
+ print("path: %s" % details.info.path)
194
+ print("serial: %s" % (details.info.serial or ""))
195
+ print("firmware: %s" % details.firmware)
196
+ print("geometry: %dx%d" % (details.geometry.width, details.geometry.height))
197
+ print("frame bytes: %d" % details.geometry.frame_bytes)
198
+ print("vblank ack: %s" % details.capabilities.vblank_ack)
199
+ elif args.command == "clear":
200
+ device.show_frame(Canvas().clear())
201
+ elif args.command == "pixel":
202
+ device.show_frame(Canvas().clear().set_pixel(args.x, args.y, True))
203
+ elif args.command == "rect":
204
+ device.show_frame(Canvas().clear().fill_rect(args.x, args.y, args.width, args.height, True))
205
+ elif args.command == "brightness":
206
+ device.set_brightness(args.value)
207
+ elif args.command == "image":
208
+ from PIL import Image
209
+
210
+ with Image.open(args.path) as source:
211
+ frame = ImagePipeline(dither=args.dither, resize=args.resize).process(source)
212
+ device.show_frame(frame)
213
+ elif args.command == "text":
214
+ canvas = _logical_canvas(args.rotate).clear()
215
+ Font.load(args.font).draw_text(canvas, args.x, args.y, args.text)
216
+ canvas = _rotate_canvas(canvas, args.rotate)
217
+ if args.preview:
218
+ _print_preview(canvas)
219
+ device.show_frame(canvas)
220
+ elif args.command == "orientation-test":
221
+ canvas = _orientation_test_canvas()
222
+ if args.preview:
223
+ _print_preview(canvas)
224
+ device.show_frame(canvas)
225
+ elif args.command == "raw":
226
+ response = device.raw_command(args.opcode, args.payload, response_bytes=args.response_bytes)
227
+ if response:
228
+ print(response.hex(" "))
229
+ else:
230
+ raise LedMatrixError("unknown command %r" % args.command)
231
+ _print_dry_run(mock)
232
+ return 0
233
+ except LedMatrixError as exc:
234
+ print("error: %s" % exc, file=sys.stderr)
235
+ return 1
236
+
237
+
238
+ def main(argv: Optional[Sequence[str]] = None) -> int:
239
+ parser = build_parser()
240
+ args = parser.parse_args(argv)
241
+ try:
242
+ return run(args)
243
+ except LedMatrixError as exc:
244
+ parser.exit(2, "ledmatrix: error: %s\n" % exc)
245
+ except ValueError as exc:
246
+ parser.exit(2, "ledmatrix: error: %s\n" % exc)
247
+
248
+
249
+ if __name__ == "__main__": # pragma: no cover
250
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ # Framework Laptop 16 Input Modules: USB CDC ACM serial endpoints
2
+ # Install to /etc/udev/rules.d/ and reload udev. Review and adapt group/mode for your system.
3
+ SUBSYSTEM=="tty", ATTRS{idVendor}=="32ac", ATTRS{idProduct}=="0020", MODE="0660", GROUP="plugdev", TAG+="uaccess"
@@ -0,0 +1 @@
1
+ """Packaged non-code resources for ledmatrix."""