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.
@@ -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,10 @@
1
+ class DisplayPadError(Exception):
2
+ """Base error for displaypad package."""
3
+
4
+
5
+ class TransportError(DisplayPadError):
6
+ """Transport/USB related error."""
7
+
8
+
9
+ class DeviceNotFoundError(DisplayPadError):
10
+ """Raised when the device cannot be located."""
@@ -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
+