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 +33 -0
- ledmatrix/async_device.py +64 -0
- ledmatrix/canvas.py +208 -0
- ledmatrix/cli/__init__.py +1 -0
- ledmatrix/cli/main.py +250 -0
- ledmatrix/data/50-framework-inputmodule.rules +3 -0
- ledmatrix/data/__init__.py +1 -0
- ledmatrix/device.py +367 -0
- ledmatrix/dither.py +95 -0
- ledmatrix/exceptions.py +41 -0
- ledmatrix/font/__init__.py +5 -0
- ledmatrix/font/bdf.py +112 -0
- ledmatrix/font/data/3x5.bdf +946 -0
- ledmatrix/font/data/5x7.bdf +1102 -0
- ledmatrix/font/data/__init__.py +1 -0
- ledmatrix/font/data/tom-thumb.bdf +1024 -0
- ledmatrix/font/font.py +111 -0
- ledmatrix/geometry.py +49 -0
- ledmatrix/hotplug.py +75 -0
- ledmatrix/image.py +56 -0
- ledmatrix/logging.py +21 -0
- ledmatrix/protocol/__init__.py +8 -0
- ledmatrix/protocol/framework16.py +66 -0
- ledmatrix/protocol/types.py +57 -0
- ledmatrix/scheduler.py +67 -0
- ledmatrix/shapes.py +36 -0
- ledmatrix/transport/__init__.py +9 -0
- ledmatrix/transport/base.py +30 -0
- ledmatrix/transport/mock.py +47 -0
- ledmatrix/transport/serial.py +102 -0
- ledmatrix-0.1.0.dist-info/METADATA +287 -0
- ledmatrix-0.1.0.dist-info/RECORD +36 -0
- ledmatrix-0.1.0.dist-info/WHEEL +5 -0
- ledmatrix-0.1.0.dist-info/entry_points.txt +2 -0
- ledmatrix-0.1.0.dist-info/licenses/LICENSE +21 -0
- ledmatrix-0.1.0.dist-info/top_level.txt +1 -0
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."""
|