displaypad-driver 1.0.0__tar.gz
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.
- displaypad_driver-1.0.0/PKG-INFO +40 -0
- displaypad_driver-1.0.0/README.md +24 -0
- displaypad_driver-1.0.0/pyproject.toml +20 -0
- displaypad_driver-1.0.0/src/displaypad_driver/__init__.py +41 -0
- displaypad_driver-1.0.0/src/displaypad_driver/device.py +222 -0
- displaypad_driver-1.0.0/src/displaypad_driver/exceptions.py +10 -0
- displaypad_driver-1.0.0/src/displaypad_driver/image.py +187 -0
- displaypad_driver-1.0.0/src/displaypad_driver/protocol.py +46 -0
- displaypad_driver-1.0.0/src/displaypad_driver/transport.py +143 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: displaypad-driver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Driver to communicate with the mountain displaypad.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: AnnikenToGo
|
|
7
|
+
Author-email: anniken@annikentogo.de
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Dist: pillow (>=12.1.0,<13.0.0)
|
|
13
|
+
Requires-Dist: pyusb (>=1.3.1,<2.0.0)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# displaypad-driver package
|
|
17
|
+
|
|
18
|
+
`displaypad-driver` provides a thread-safe, high-performance driver for the Mountain DisplayPad device.
|
|
19
|
+
|
|
20
|
+
## Acknowledgments
|
|
21
|
+
Performance optimizations, dual-interface transport handling, initialization handshakes, and input report interleaving were built with inspiration from [ramisotti13-eng/BaseCamp-Linux](https://github.com/ramisotti13-eng/BaseCamp-Linux).
|
|
22
|
+
|
|
23
|
+
## Modules
|
|
24
|
+
|
|
25
|
+
- `transport.py` — Low-level interface opener (`open_interfaces`, `init_handshake_ctrl`, `close_interfaces`). Claims Interface 1 (PyUSB display bulk transfer endpoint `0x02`) and Interface 3 (`hidapi` command/event endpoint), handles temporary IF0 kernel driver detachment to send HID `SET_IDLE` and `SET_REPORT` commands.
|
|
26
|
+
- `device.py` — Thread-safe `DisplayPad` manager.
|
|
27
|
+
- `upload_button(key_index, bgr_pixels)` — Uploads a 102×102 BGR tile to a specific key slot (0–11) with non-blocking HID report interleaving.
|
|
28
|
+
- `upload_panel(tiles_bgr)` — Uploads 12 tile payloads in batch.
|
|
29
|
+
- `poll_key(timeout)` — Non-blocking polling returning `pressed`, `released`, and `current` key lists.
|
|
30
|
+
- `set_brightness(percent)` — Adjusts backlight brightness (0–100%).
|
|
31
|
+
- `protocol.py` — VID/PID constants, payload headers, INIT/IMG templates, and `get_pressed_keys` bitmask parser.
|
|
32
|
+
- `image.py` — Image processing utilities:
|
|
33
|
+
- `image_to_bgr102(img, rotation)` — Converts PIL Image to 102×102 BGR bytes with 0°/90°/180°/270° rotation.
|
|
34
|
+
- `split_image_to_tiles(img, rotation)` — Slices full-panel 612×204 images into 12 BGR tile payloads.
|
|
35
|
+
- `split_gif_to_tiles(gif)` & `load_gif_frames(gif)` — Animated GIF parser.
|
|
36
|
+
- `make_label_icon()` & `make_folder_icon()` — Dynamic text label and icon generator.
|
|
37
|
+
|
|
38
|
+
For a usage example, see [driver_example.py](../../examples/driver_example.py).
|
|
39
|
+
|
|
40
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# displaypad-driver package
|
|
2
|
+
|
|
3
|
+
`displaypad-driver` provides a thread-safe, high-performance driver for the Mountain DisplayPad device.
|
|
4
|
+
|
|
5
|
+
## Acknowledgments
|
|
6
|
+
Performance optimizations, dual-interface transport handling, initialization handshakes, and input report interleaving were built with inspiration from [ramisotti13-eng/BaseCamp-Linux](https://github.com/ramisotti13-eng/BaseCamp-Linux).
|
|
7
|
+
|
|
8
|
+
## Modules
|
|
9
|
+
|
|
10
|
+
- `transport.py` — Low-level interface opener (`open_interfaces`, `init_handshake_ctrl`, `close_interfaces`). Claims Interface 1 (PyUSB display bulk transfer endpoint `0x02`) and Interface 3 (`hidapi` command/event endpoint), handles temporary IF0 kernel driver detachment to send HID `SET_IDLE` and `SET_REPORT` commands.
|
|
11
|
+
- `device.py` — Thread-safe `DisplayPad` manager.
|
|
12
|
+
- `upload_button(key_index, bgr_pixels)` — Uploads a 102×102 BGR tile to a specific key slot (0–11) with non-blocking HID report interleaving.
|
|
13
|
+
- `upload_panel(tiles_bgr)` — Uploads 12 tile payloads in batch.
|
|
14
|
+
- `poll_key(timeout)` — Non-blocking polling returning `pressed`, `released`, and `current` key lists.
|
|
15
|
+
- `set_brightness(percent)` — Adjusts backlight brightness (0–100%).
|
|
16
|
+
- `protocol.py` — VID/PID constants, payload headers, INIT/IMG templates, and `get_pressed_keys` bitmask parser.
|
|
17
|
+
- `image.py` — Image processing utilities:
|
|
18
|
+
- `image_to_bgr102(img, rotation)` — Converts PIL Image to 102×102 BGR bytes with 0°/90°/180°/270° rotation.
|
|
19
|
+
- `split_image_to_tiles(img, rotation)` — Slices full-panel 612×204 images into 12 BGR tile payloads.
|
|
20
|
+
- `split_gif_to_tiles(gif)` & `load_gif_frames(gif)` — Animated GIF parser.
|
|
21
|
+
- `make_label_icon()` & `make_folder_icon()` — Dynamic text label and icon generator.
|
|
22
|
+
|
|
23
|
+
For a usage example, see [driver_example.py](../../examples/driver_example.py).
|
|
24
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "displaypad-driver"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
|
|
5
|
+
description = "A Driver to communicate with the mountain displaypad."
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "AnnikenToGo",email = "anniken@annikentogo.de"}
|
|
8
|
+
]
|
|
9
|
+
license = {text = "MIT"}
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pillow (>=12.1.0,<13.0.0)",
|
|
14
|
+
"pyusb (>=1.3.1,<2.0.0)"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
20
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""DisplayPad package exports."""
|
|
2
|
+
|
|
3
|
+
from .device import DisplayPad
|
|
4
|
+
from .image import (
|
|
5
|
+
image_to_bgr102, split_image_to_tiles, split_gif_to_tiles,
|
|
6
|
+
load_gif_frames, make_label_icon, make_folder_icon
|
|
7
|
+
)
|
|
8
|
+
from .exceptions import DisplayPadError, TransportError, DeviceNotFoundError
|
|
9
|
+
from .protocol import (
|
|
10
|
+
VID, PID, NUM_KEYS, KEYS_PER_ROW, ICON_SIZE, CHUNK_SIZE,
|
|
11
|
+
HEADER_SIZE, PACKET_SIZE, EP_DISPLAY, EP_CMD, EP_IN
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"DisplayPad",
|
|
20
|
+
|
|
21
|
+
"image_to_bgr102",
|
|
22
|
+
"split_image_to_tiles",
|
|
23
|
+
"split_gif_to_tiles",
|
|
24
|
+
"load_gif_frames",
|
|
25
|
+
"make_label_icon",
|
|
26
|
+
"make_folder_icon",
|
|
27
|
+
"DisplayPadError",
|
|
28
|
+
"TransportError",
|
|
29
|
+
"DeviceNotFoundError",
|
|
30
|
+
"VID",
|
|
31
|
+
"PID",
|
|
32
|
+
"NUM_KEYS",
|
|
33
|
+
"KEYS_PER_ROW",
|
|
34
|
+
"ICON_SIZE",
|
|
35
|
+
"CHUNK_SIZE",
|
|
36
|
+
"HEADER_SIZE",
|
|
37
|
+
"PACKET_SIZE",
|
|
38
|
+
"EP_DISPLAY",
|
|
39
|
+
"EP_CMD",
|
|
40
|
+
"EP_IN",
|
|
41
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Higher-level DisplayPad device API built on top of the USB transport."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import List, Dict, Optional, Tuple, Set
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from .exceptions import DisplayPadError, TransportError, DeviceNotFoundError
|
|
10
|
+
from .protocol import (
|
|
11
|
+
VID, PID, NUM_KEYS, ICON_SIZE, CHUNK_SIZE, HEADER_SIZE, PACKET_SIZE,
|
|
12
|
+
EP_DISPLAY, INIT_MSG, IMG_MSG_TEMPLATE, KEY_MAP, get_pressed_keys
|
|
13
|
+
)
|
|
14
|
+
from .transport import open_interfaces, close_interfaces, check_dependencies
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DisplayPad:
|
|
20
|
+
"""Object representing the DisplayPad device.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
with DisplayPad() as d:
|
|
24
|
+
d.set_brightness(50)
|
|
25
|
+
d.upload_button(0, bgr_data)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, vendor_id: int = VID, product_id: int = PID):
|
|
29
|
+
self.vendor_id = vendor_id
|
|
30
|
+
self.product_id = product_id
|
|
31
|
+
self.usb_dev = None
|
|
32
|
+
self.hid_dev = None
|
|
33
|
+
self.pressed_keys: Set[int] = set()
|
|
34
|
+
self.connected = False
|
|
35
|
+
self._usb_lock = threading.Lock()
|
|
36
|
+
self._pending_key_packets: List[bytes] = []
|
|
37
|
+
|
|
38
|
+
self.connect()
|
|
39
|
+
|
|
40
|
+
def connect(self):
|
|
41
|
+
"""Open USB interfaces and execute initialization handshake."""
|
|
42
|
+
with self._usb_lock:
|
|
43
|
+
if self.connected:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
self.usb_dev, self.hid_dev = open_interfaces()
|
|
47
|
+
self._init_device()
|
|
48
|
+
self.connected = True
|
|
49
|
+
|
|
50
|
+
def close(self):
|
|
51
|
+
"""Close USB interfaces and release resources."""
|
|
52
|
+
with self._usb_lock:
|
|
53
|
+
if self.connected:
|
|
54
|
+
close_interfaces(self.usb_dev, self.hid_dev)
|
|
55
|
+
self.usb_dev = None
|
|
56
|
+
self.hid_dev = None
|
|
57
|
+
self.connected = False
|
|
58
|
+
|
|
59
|
+
def __enter__(self):
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
63
|
+
self.close()
|
|
64
|
+
|
|
65
|
+
def _init_device(self):
|
|
66
|
+
"""Send INIT_MSG on Interface 3 and wait for a matching echo with 250ms firmware settling sleep."""
|
|
67
|
+
if not self.hid_dev:
|
|
68
|
+
raise DisplayPadError("HID device interface not open")
|
|
69
|
+
|
|
70
|
+
pkt = INIT_MSG
|
|
71
|
+
echo = pkt[1:6]
|
|
72
|
+
ack_received = False
|
|
73
|
+
|
|
74
|
+
for _attempt in range(60):
|
|
75
|
+
try:
|
|
76
|
+
self.hid_dev.write(pkt)
|
|
77
|
+
except Exception:
|
|
78
|
+
time.sleep(0.01)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
resp = self.hid_dev.read(64, timeout=500)
|
|
83
|
+
except Exception:
|
|
84
|
+
resp = None
|
|
85
|
+
|
|
86
|
+
if not resp:
|
|
87
|
+
time.sleep(0.01)
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if len(resp) >= 5 and bytes(resp[:5]) == echo:
|
|
91
|
+
# Firmware settling delay before display engine is ready
|
|
92
|
+
time.sleep(0.25)
|
|
93
|
+
ack_received = True
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
if not ack_received:
|
|
97
|
+
raise DisplayPadError("DisplayPad did not respond to INIT handshake")
|
|
98
|
+
|
|
99
|
+
def set_brightness(self, percent: int = 100):
|
|
100
|
+
"""Set DisplayPad backlight brightness. percent: 0 to 100."""
|
|
101
|
+
with self._usb_lock:
|
|
102
|
+
if not self.hid_dev:
|
|
103
|
+
raise DisplayPadError("Device not connected")
|
|
104
|
+
|
|
105
|
+
percent = max(0, min(100, int(percent)))
|
|
106
|
+
buf = bytearray(64)
|
|
107
|
+
buf[0] = 0x12
|
|
108
|
+
buf[1] = 0x03
|
|
109
|
+
buf[4] = percent
|
|
110
|
+
try:
|
|
111
|
+
self.hid_dev.write(bytes(buf))
|
|
112
|
+
except Exception as e:
|
|
113
|
+
raise DisplayPadError(f"Failed to set brightness: {e}")
|
|
114
|
+
|
|
115
|
+
def upload_button(self, key_index: int, bgr_pixels: bytes, key_events: Optional[list] = None):
|
|
116
|
+
"""Upload a 102x102 BGR image payload to a specific button (key_index 0..11).
|
|
117
|
+
|
|
118
|
+
If key_events list is provided or key events arrive during ACK wait,
|
|
119
|
+
key event reports are buffered so no keypresses are lost.
|
|
120
|
+
"""
|
|
121
|
+
if not (0 <= key_index < NUM_KEYS):
|
|
122
|
+
raise ValueError(f"key_index must be between 0 and {NUM_KEYS - 1}")
|
|
123
|
+
|
|
124
|
+
with self._usb_lock:
|
|
125
|
+
if not self.usb_dev or not self.hid_dev:
|
|
126
|
+
raise DisplayPadError("Device not connected")
|
|
127
|
+
|
|
128
|
+
# Step 1: Send image message template targeting key_index
|
|
129
|
+
msg = bytearray(IMG_MSG_TEMPLATE)
|
|
130
|
+
msg[5] = key_index
|
|
131
|
+
self.hid_dev.write(bytes(msg))
|
|
132
|
+
|
|
133
|
+
# Step 2: Wait for readiness ACK (0x21 0x00 0x00), buffering incoming key events
|
|
134
|
+
for _ in range(100):
|
|
135
|
+
resp = self.hid_dev.read(64, timeout=5)
|
|
136
|
+
if resp and len(resp) >= 3 and resp[0] == 0x21 and resp[1] == 0x00 and resp[2] == 0x00:
|
|
137
|
+
break
|
|
138
|
+
if resp and len(resp) >= 48 and resp[0] == 0x01:
|
|
139
|
+
raw_evt = bytes(resp)
|
|
140
|
+
if not self._pending_key_packets or self._pending_key_packets[-1] != raw_evt:
|
|
141
|
+
self._pending_key_packets.append(raw_evt)
|
|
142
|
+
if key_events is not None:
|
|
143
|
+
key_events.append(list(resp))
|
|
144
|
+
else:
|
|
145
|
+
raise DisplayPadError(f"No ready response for key {key_index}")
|
|
146
|
+
|
|
147
|
+
# Step 3: Write payload (HEADER_SIZE + PACKET_SIZE) in 1024-byte chunks
|
|
148
|
+
payload = bytearray(HEADER_SIZE + PACKET_SIZE)
|
|
149
|
+
payload[HEADER_SIZE:HEADER_SIZE + len(bgr_pixels)] = bgr_pixels
|
|
150
|
+
|
|
151
|
+
for i in range(0, len(payload), CHUNK_SIZE):
|
|
152
|
+
self.usb_dev.write(EP_DISPLAY, bytes(payload[i:i + CHUNK_SIZE]), timeout=1000)
|
|
153
|
+
|
|
154
|
+
# Step 4: Wait for confirmation ACK (0x21 0x00 0xFF), buffering incoming key events
|
|
155
|
+
for _ in range(100):
|
|
156
|
+
resp = self.hid_dev.read(64, timeout=5)
|
|
157
|
+
if resp and len(resp) >= 3 and resp[0] == 0x21 and resp[1] == 0x00 and resp[2] == 0xFF:
|
|
158
|
+
return
|
|
159
|
+
if resp and len(resp) >= 48 and resp[0] == 0x01:
|
|
160
|
+
raw_evt = bytes(resp)
|
|
161
|
+
if not self._pending_key_packets or self._pending_key_packets[-1] != raw_evt:
|
|
162
|
+
self._pending_key_packets.append(raw_evt)
|
|
163
|
+
if key_events is not None:
|
|
164
|
+
key_events.append(list(resp))
|
|
165
|
+
|
|
166
|
+
raise DisplayPadError(f"Transfer confirmation timed out for key {key_index}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def upload_panel(self, tiles_bgr: List[bytes], key_events: Optional[list] = None):
|
|
171
|
+
"""Upload BGR payloads for all 12 buttons."""
|
|
172
|
+
if len(tiles_bgr) != NUM_KEYS:
|
|
173
|
+
raise ValueError(f"Expected {NUM_KEYS} BGR tile payloads, got {len(tiles_bgr)}")
|
|
174
|
+
|
|
175
|
+
for idx, bgr in enumerate(tiles_bgr):
|
|
176
|
+
self.upload_button(idx, bgr, key_events=key_events)
|
|
177
|
+
|
|
178
|
+
def read_raw_report(self, timeout: int = 150) -> Optional[bytes]:
|
|
179
|
+
"""Read a raw HID report from Interface 3."""
|
|
180
|
+
with self._usb_lock:
|
|
181
|
+
if not self.hid_dev:
|
|
182
|
+
return None
|
|
183
|
+
try:
|
|
184
|
+
data = self.hid_dev.read(64, timeout=timeout)
|
|
185
|
+
return bytes(data) if data else None
|
|
186
|
+
except Exception as e:
|
|
187
|
+
log.debug("read_raw_report failed: %s", e)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def poll_key(self, timeout: int = 150) -> Dict[str, List[int]]:
|
|
191
|
+
"""Poll for key events and return newly pressed, newly released, and current key lists.
|
|
192
|
+
|
|
193
|
+
Drains buffered key events captured during image updates first.
|
|
194
|
+
"""
|
|
195
|
+
raw = None
|
|
196
|
+
with self._usb_lock:
|
|
197
|
+
if self._pending_key_packets:
|
|
198
|
+
raw = self._pending_key_packets.pop(0)
|
|
199
|
+
elif self.hid_dev:
|
|
200
|
+
try:
|
|
201
|
+
data = self.hid_dev.read(64, timeout=timeout)
|
|
202
|
+
raw = bytes(data) if data else None
|
|
203
|
+
except Exception as e:
|
|
204
|
+
log.debug("poll_key read failed: %s", e)
|
|
205
|
+
|
|
206
|
+
if not raw or len(raw) < 48 or raw[0] != 0x01:
|
|
207
|
+
return {
|
|
208
|
+
'pressed': [],
|
|
209
|
+
'released': [],
|
|
210
|
+
'current': sorted(list(self.pressed_keys))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
current_pressed = set(get_pressed_keys(raw))
|
|
214
|
+
newly_pressed = list(current_pressed - self.pressed_keys)
|
|
215
|
+
newly_released = list(self.pressed_keys - current_pressed)
|
|
216
|
+
self.pressed_keys = current_pressed
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
'pressed': sorted(newly_pressed),
|
|
220
|
+
'released': sorted(newly_released),
|
|
221
|
+
'current': sorted(list(current_pressed))
|
|
222
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Image helpers for DisplayPad.
|
|
2
|
+
|
|
3
|
+
Provides utilities to convert, rotate, slice, and normalize image/GIF data for sending to the device.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Tuple, Optional, Dict, Union
|
|
7
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from .protocol import ICON_SIZE, KEYS_PER_ROW, NUM_KEYS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_pil_image(image_input: Union[str, Image.Image]) -> Image.Image:
|
|
14
|
+
if isinstance(image_input, Image.Image):
|
|
15
|
+
return image_input.copy()
|
|
16
|
+
return Image.open(image_input)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def image_to_bgr102(image_input: Union[str, Image.Image], rotation: int = 0) -> bytes:
|
|
20
|
+
"""Convert an image (file path or PIL Image) to 102x102 raw BGR bytes."""
|
|
21
|
+
img = _to_pil_image(image_input).convert("RGB").resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS)
|
|
22
|
+
if rotation:
|
|
23
|
+
img = img.rotate(-rotation, expand=False) # PIL rotates CCW, hardware wants CW
|
|
24
|
+
r, g, b = img.split()
|
|
25
|
+
return Image.merge("RGB", (b, g, r)).tobytes()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def split_image_to_tiles(image_input: Union[str, Image.Image], rotation: int = 0) -> List[bytes]:
|
|
29
|
+
"""Split a full panel image (612x204 nominal grid) into 12 BGR102 tile byte payloads."""
|
|
30
|
+
grid_w = ICON_SIZE * KEYS_PER_ROW
|
|
31
|
+
grid_h = ICON_SIZE * (NUM_KEYS // KEYS_PER_ROW)
|
|
32
|
+
img = _to_pil_image(image_input).convert("RGB").resize((grid_w, grid_h), Image.LANCZOS)
|
|
33
|
+
|
|
34
|
+
tiles = []
|
|
35
|
+
for idx in range(NUM_KEYS):
|
|
36
|
+
row = idx // KEYS_PER_ROW
|
|
37
|
+
col = idx % KEYS_PER_ROW
|
|
38
|
+
x, y = col * ICON_SIZE, row * ICON_SIZE
|
|
39
|
+
tile = img.crop((x, y, x + ICON_SIZE, y + ICON_SIZE))
|
|
40
|
+
if rotation:
|
|
41
|
+
tile = tile.rotate(-rotation, expand=False)
|
|
42
|
+
r, g, b = tile.split()
|
|
43
|
+
bgr = Image.merge("RGB", (b, g, r)).tobytes()
|
|
44
|
+
tiles.append(bgr)
|
|
45
|
+
|
|
46
|
+
return tiles
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_gif_frames(image_input: Union[str, Image.Image], rotation: int = 0) -> Optional[List[Tuple[bytes, int]]]:
|
|
50
|
+
"""Extract frames from an animated GIF.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of (bgr_bytes, duration_ms) or None if image is not animated.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
img = _to_pil_image(image_input)
|
|
57
|
+
if not getattr(img, 'is_animated', False) and getattr(img, 'n_frames', 1) <= 1:
|
|
58
|
+
return None
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
frames = []
|
|
63
|
+
try:
|
|
64
|
+
for i in range(img.n_frames):
|
|
65
|
+
img.seek(i)
|
|
66
|
+
duration = max(img.info.get('duration', 100), 20)
|
|
67
|
+
frame = img.convert("RGB").resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS)
|
|
68
|
+
if rotation:
|
|
69
|
+
frame = frame.rotate(-rotation, expand=False)
|
|
70
|
+
r, g, b = frame.split()
|
|
71
|
+
bgr = Image.merge("RGB", (b, g, r)).tobytes()
|
|
72
|
+
frames.append((bgr, duration))
|
|
73
|
+
except EOFError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return frames if len(frames) > 1 else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def split_gif_to_tiles(image_input: Union[str, Image.Image], rotation: int = 0) -> Optional[Dict[int, List[Tuple[bytes, int]]]]:
|
|
80
|
+
"""Split an animated GIF into 12 synchronized tile frame lists.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
{key_idx: [(bgr_bytes, duration_ms), ...]} or None if not animated.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
img = _to_pil_image(image_input)
|
|
87
|
+
if not getattr(img, 'is_animated', False) and getattr(img, 'n_frames', 1) <= 1:
|
|
88
|
+
return None
|
|
89
|
+
except Exception:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
grid_w = ICON_SIZE * KEYS_PER_ROW
|
|
93
|
+
grid_h = ICON_SIZE * (NUM_KEYS // KEYS_PER_ROW)
|
|
94
|
+
result = {k: [] for k in range(NUM_KEYS)}
|
|
95
|
+
try:
|
|
96
|
+
for i in range(img.n_frames):
|
|
97
|
+
img.seek(i)
|
|
98
|
+
duration = max(img.info.get('duration', 100), 20)
|
|
99
|
+
frame = img.convert("RGB").resize((grid_w, grid_h), Image.LANCZOS)
|
|
100
|
+
for idx in range(NUM_KEYS):
|
|
101
|
+
row = idx // KEYS_PER_ROW
|
|
102
|
+
col = idx % KEYS_PER_ROW
|
|
103
|
+
x, y = col * ICON_SIZE, row * ICON_SIZE
|
|
104
|
+
tile = frame.crop((x, y, x + ICON_SIZE, y + ICON_SIZE))
|
|
105
|
+
if rotation:
|
|
106
|
+
tile = tile.rotate(-rotation, expand=False)
|
|
107
|
+
r, g, b = tile.split()
|
|
108
|
+
result[idx].append((Image.merge("RGB", (b, g, r)).tobytes(), duration))
|
|
109
|
+
except EOFError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
return result if result[0] and len(result[0]) > 1 else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def make_label_icon(text: str, out_path: Optional[str] = None) -> Image.Image:
|
|
116
|
+
"""Render a short text label centered on a 102x102 tile with dark background and shadow."""
|
|
117
|
+
img = Image.new("RGB", (ICON_SIZE, ICON_SIZE), (28, 28, 36))
|
|
118
|
+
draw = ImageDraw.Draw(img)
|
|
119
|
+
|
|
120
|
+
label = (text or "").strip()
|
|
121
|
+
font = ImageFont.load_default()
|
|
122
|
+
|
|
123
|
+
for size in (30, 26, 22, 18, 15, 12):
|
|
124
|
+
try:
|
|
125
|
+
for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
126
|
+
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf"):
|
|
127
|
+
if os.path.exists(p):
|
|
128
|
+
font = ImageFont.truetype(p, size)
|
|
129
|
+
break
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
words = label.split()
|
|
134
|
+
lines, cur = [], ""
|
|
135
|
+
for w in words or [label]:
|
|
136
|
+
trial = (cur + " " + w).strip()
|
|
137
|
+
if draw.textlength(trial, font=font) <= ICON_SIZE - 8 or not cur:
|
|
138
|
+
cur = trial
|
|
139
|
+
else:
|
|
140
|
+
lines.append(cur)
|
|
141
|
+
cur = w
|
|
142
|
+
if cur:
|
|
143
|
+
lines.append(cur)
|
|
144
|
+
lines = lines[:3]
|
|
145
|
+
line_h = (draw.textbbox((0, 0), "Ag", font=font)[3]) + 2
|
|
146
|
+
if line_h * len(lines) <= ICON_SIZE - 6 and all(
|
|
147
|
+
draw.textlength(ln, font=font) <= ICON_SIZE - 6 for ln in lines):
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
total_h = line_h * len(lines)
|
|
151
|
+
y = max(2, (ICON_SIZE - total_h) // 2)
|
|
152
|
+
for ln in lines:
|
|
153
|
+
tw = draw.textlength(ln, font=font)
|
|
154
|
+
x = max(2, (ICON_SIZE - tw) // 2)
|
|
155
|
+
draw.text((x + 1, y + 1), ln, fill=(0, 0, 0), font=font)
|
|
156
|
+
draw.text((x, y), ln, fill=(255, 255, 255), font=font)
|
|
157
|
+
y += line_h
|
|
158
|
+
|
|
159
|
+
if out_path:
|
|
160
|
+
img.save(out_path, "PNG")
|
|
161
|
+
return img
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def make_folder_icon(base_path: str, label: str, out_path: Optional[str] = None) -> Image.Image:
|
|
165
|
+
"""Render label text on top of a folder icon base image."""
|
|
166
|
+
img = Image.open(base_path).convert("RGB").resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS)
|
|
167
|
+
if label:
|
|
168
|
+
draw = ImageDraw.Draw(img)
|
|
169
|
+
font = ImageFont.load_default()
|
|
170
|
+
for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
171
|
+
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf"):
|
|
172
|
+
if os.path.exists(p):
|
|
173
|
+
try:
|
|
174
|
+
font = ImageFont.truetype(p, 16)
|
|
175
|
+
break
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
bbox = draw.textbbox((0, 0), label, font=font)
|
|
179
|
+
tw = bbox[2] - bbox[0]
|
|
180
|
+
x = max(2, (ICON_SIZE - tw) // 2)
|
|
181
|
+
draw.text((x + 1, 5), label, fill=(0, 0, 0), font=font)
|
|
182
|
+
draw.text((x, 4), label, fill=(255, 255, 255), font=font)
|
|
183
|
+
|
|
184
|
+
if out_path:
|
|
185
|
+
img.save(out_path, "PNG")
|
|
186
|
+
return img
|
|
187
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Protocol helpers and constants for the DisplayPad device."""
|
|
2
|
+
|
|
3
|
+
VID = 0x3282
|
|
4
|
+
PID = 0x0009
|
|
5
|
+
|
|
6
|
+
NUM_KEYS = 12
|
|
7
|
+
KEYS_PER_ROW = 6
|
|
8
|
+
ICON_SIZE = 102
|
|
9
|
+
CHUNK_SIZE = 1024
|
|
10
|
+
HEADER_SIZE = 306
|
|
11
|
+
PACKET_SIZE = 31438 # total payload = 31744 = 31 × 1024
|
|
12
|
+
EP_DISPLAY = 0x02
|
|
13
|
+
EP_CMD = 0x04
|
|
14
|
+
EP_IN = 0x83
|
|
15
|
+
|
|
16
|
+
# Key-event byte/bit map: K1-K7 -> data[42], K8-K12 -> data[47]
|
|
17
|
+
KEY_MAP = (
|
|
18
|
+
[(42, m) for m in (0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80)] +
|
|
19
|
+
[(47, m) for m in (0x01, 0x02, 0x04, 0x08, 0x10)]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
INIT_MSG = bytes.fromhex(
|
|
23
|
+
"0011800000010000000000000000000000000000000000000000000000000000"
|
|
24
|
+
"00000000000000000000000000000000000000000000000000000000000000"
|
|
25
|
+
"0000"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
IMG_MSG_TEMPLATE = bytearray.fromhex(
|
|
29
|
+
"0021000000FF3d00006565000000000000000000000000000000000000000000"
|
|
30
|
+
"00000000000000000000000000000000000000000000000000000000000000"
|
|
31
|
+
"0000"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_pressed_keys(msg: bytes) -> list:
|
|
36
|
+
"""Extract the list of currently pressed keys from the message (0-indexed)."""
|
|
37
|
+
pressed_keys = []
|
|
38
|
+
if not msg or len(msg) < 48 or msg[0] != 0x01:
|
|
39
|
+
return pressed_keys
|
|
40
|
+
|
|
41
|
+
for idx, (byte_idx, mask) in enumerate(KEY_MAP):
|
|
42
|
+
if byte_idx < len(msg) and (msg[byte_idx] & mask):
|
|
43
|
+
pressed_keys.append(idx)
|
|
44
|
+
|
|
45
|
+
return pressed_keys
|
|
46
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""USB transport layer for the DisplayPad device (Interface 1 bulk OUT + Interface 3 hidraw)."""
|
|
2
|
+
|
|
3
|
+
import gc
|
|
4
|
+
import time
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from typing import Tuple, Optional
|
|
7
|
+
|
|
8
|
+
from .exceptions import TransportError, DeviceNotFoundError
|
|
9
|
+
from .protocol import VID, PID
|
|
10
|
+
|
|
11
|
+
log = getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import hid
|
|
15
|
+
HID_AVAILABLE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
HID_AVAILABLE = False
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import usb.core
|
|
21
|
+
import usb.util
|
|
22
|
+
PYUSB_AVAILABLE = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
PYUSB_AVAILABLE = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_dependencies():
|
|
28
|
+
if not HID_AVAILABLE:
|
|
29
|
+
raise TransportError("hidapi is not installed (pip install hid)")
|
|
30
|
+
if not PYUSB_AVAILABLE:
|
|
31
|
+
raise TransportError("PyUSB is not installed (pip install pyusb)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def open_interfaces() -> Tuple[usb.core.Device, hid.Device]:
|
|
35
|
+
"""Open PyUSB device (Interface 1 for pixel bulk data) and HID device (Interface 3 for commands/events)."""
|
|
36
|
+
check_dependencies()
|
|
37
|
+
gc.collect()
|
|
38
|
+
|
|
39
|
+
device_path = None
|
|
40
|
+
for d in hid.enumerate(VID, PID):
|
|
41
|
+
if d.get('interface_number') == 3:
|
|
42
|
+
device_path = d.get('path')
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
if device_path is None:
|
|
46
|
+
raise DeviceNotFoundError(f"DisplayPad Interface 3 not found ({hex(VID)}:{hex(PID)})")
|
|
47
|
+
|
|
48
|
+
last_err = None
|
|
49
|
+
for attempt in range(3):
|
|
50
|
+
hid_dev = None
|
|
51
|
+
try:
|
|
52
|
+
hid_dev = hid.Device(path=device_path)
|
|
53
|
+
hid_dev.nonblocking = False
|
|
54
|
+
usb_dev = usb.core.find(idVendor=VID, idProduct=PID)
|
|
55
|
+
if usb_dev is None:
|
|
56
|
+
hid_dev.close()
|
|
57
|
+
raise DeviceNotFoundError("DisplayPad not found via PyUSB")
|
|
58
|
+
|
|
59
|
+
usb.util.claim_interface(usb_dev, 1)
|
|
60
|
+
init_handshake_ctrl(usb_dev)
|
|
61
|
+
return usb_dev, hid_dev
|
|
62
|
+
except Exception as e:
|
|
63
|
+
last_err = e
|
|
64
|
+
if hid_dev is not None:
|
|
65
|
+
try:
|
|
66
|
+
hid_dev.close()
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
time.sleep(0.2)
|
|
70
|
+
|
|
71
|
+
raise TransportError(f"DisplayPad open failed: {last_err}") if last_err else DeviceNotFoundError("Open failed")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def init_handshake_ctrl(usb_dev):
|
|
75
|
+
"""
|
|
76
|
+
Step 1: Detach kernel driver on IF0 (keyboard) if active.
|
|
77
|
+
Step 2: SET_IDLE on IF0, IF1 (pixels), IF3 (cmd) to suppress report flooding.
|
|
78
|
+
Step 3: SET_REPORT (payload {0x03, 0x01}) on IF0 to enable event reporting mode.
|
|
79
|
+
Step 4: Release IF0 and reattach its kernel driver so OS keyboard keeps working.
|
|
80
|
+
"""
|
|
81
|
+
if0_was_active = False
|
|
82
|
+
try:
|
|
83
|
+
if0_was_active = bool(usb_dev.is_kernel_driver_active(0))
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
if if0_was_active:
|
|
88
|
+
try:
|
|
89
|
+
usb_dev.detach_kernel_driver(0)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
if0_claimed = False
|
|
94
|
+
try:
|
|
95
|
+
usb.util.claim_interface(usb_dev, 0)
|
|
96
|
+
if0_claimed = True
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def _set_idle(iface):
|
|
101
|
+
try:
|
|
102
|
+
usb_dev.ctrl_transfer(0x21, 0x0A, 0x0000, iface, None, timeout=500)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
if if0_claimed:
|
|
107
|
+
_set_idle(0)
|
|
108
|
+
_set_idle(1) # IF_PIXELS
|
|
109
|
+
_set_idle(3) # IF_CMD
|
|
110
|
+
|
|
111
|
+
if if0_claimed:
|
|
112
|
+
try:
|
|
113
|
+
usb_dev.ctrl_transfer(0x21, 0x09, 0x0203, 0x0000, bytes([0x03, 0x01]), timeout=500)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
try:
|
|
117
|
+
usb.util.release_interface(usb_dev, 0)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
if if0_was_active:
|
|
121
|
+
try:
|
|
122
|
+
usb_dev.attach_kernel_driver(0)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def close_interfaces(usb_dev: Optional[usb.core.Device], hid_dev: Optional[hid.Device]):
|
|
128
|
+
"""Release interfaces and dispose of USB resources cleanly."""
|
|
129
|
+
if usb_dev is not None:
|
|
130
|
+
try:
|
|
131
|
+
usb.util.release_interface(usb_dev, 1)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
try:
|
|
135
|
+
usb.util.dispose_resources(usb_dev)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
if hid_dev is not None:
|
|
139
|
+
try:
|
|
140
|
+
hid_dev.close()
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|