@agentdeck/bridge 1.0.2 → 1.0.4

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.
Files changed (175) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/dist/apme/collector.d.ts.map +1 -1
  3. package/dist/apme/collector.js +5 -2
  4. package/dist/apme/collector.js.map +1 -1
  5. package/dist/apme/runner.d.ts +11 -0
  6. package/dist/apme/runner.d.ts.map +1 -1
  7. package/dist/apme/runner.js +60 -4
  8. package/dist/apme/runner.js.map +1 -1
  9. package/dist/apme/settings.d.ts +10 -0
  10. package/dist/apme/settings.d.ts.map +1 -1
  11. package/dist/apme/settings.js +14 -0
  12. package/dist/apme/settings.js.map +1 -1
  13. package/dist/awaiting-overlay.d.ts +44 -10
  14. package/dist/awaiting-overlay.d.ts.map +1 -1
  15. package/dist/awaiting-overlay.js +96 -28
  16. package/dist/awaiting-overlay.js.map +1 -1
  17. package/dist/bridge-core.d.ts +10 -0
  18. package/dist/bridge-core.d.ts.map +1 -1
  19. package/dist/bridge-core.js +47 -12
  20. package/dist/bridge-core.js.map +1 -1
  21. package/dist/card-feed.d.ts +107 -0
  22. package/dist/card-feed.d.ts.map +1 -0
  23. package/dist/card-feed.js +277 -0
  24. package/dist/card-feed.js.map +1 -0
  25. package/dist/card-modules.d.ts +87 -0
  26. package/dist/card-modules.d.ts.map +1 -0
  27. package/dist/card-modules.js +179 -0
  28. package/dist/card-modules.js.map +1 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +232 -69
  31. package/dist/cli.js.map +1 -1
  32. package/dist/daemon-server.d.ts.map +1 -1
  33. package/dist/daemon-server.js +1008 -47
  34. package/dist/daemon-server.js.map +1 -1
  35. package/dist/daemon-ws-client.d.ts +2 -1
  36. package/dist/daemon-ws-client.d.ts.map +1 -1
  37. package/dist/daemon-ws-client.js +2 -1
  38. package/dist/daemon-ws-client.js.map +1 -1
  39. package/dist/daemon.js +1 -1
  40. package/dist/device-photo.d.ts +86 -0
  41. package/dist/device-photo.d.ts.map +1 -0
  42. package/dist/device-photo.js +200 -0
  43. package/dist/device-photo.js.map +1 -0
  44. package/dist/device-voice-reply.d.ts +124 -0
  45. package/dist/device-voice-reply.d.ts.map +1 -0
  46. package/dist/device-voice-reply.js +236 -0
  47. package/dist/device-voice-reply.js.map +1 -0
  48. package/dist/device-voice.d.ts +56 -0
  49. package/dist/device-voice.d.ts.map +1 -0
  50. package/dist/device-voice.js +134 -0
  51. package/dist/device-voice.js.map +1 -0
  52. package/dist/esp32-serial.d.ts +40 -0
  53. package/dist/esp32-serial.d.ts.map +1 -1
  54. package/dist/esp32-serial.js +109 -13
  55. package/dist/esp32-serial.js.map +1 -1
  56. package/dist/foundation-models-helper.d.ts +30 -0
  57. package/dist/foundation-models-helper.d.ts.map +1 -1
  58. package/dist/foundation-models-helper.js +45 -0
  59. package/dist/foundation-models-helper.js.map +1 -1
  60. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -1
  61. package/dist/idotmatrix/idotmatrix-daemon-sync.js +5 -18
  62. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
  63. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -1
  64. package/dist/idotmatrix/idotmatrix-discover.js +6 -14
  65. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
  66. package/dist/index.js +2 -2
  67. package/dist/index.js.map +1 -1
  68. package/dist/mdns.d.ts +11 -0
  69. package/dist/mdns.d.ts.map +1 -1
  70. package/dist/mdns.js +13 -0
  71. package/dist/mdns.js.map +1 -1
  72. package/dist/observed-inject.d.ts +119 -0
  73. package/dist/observed-inject.d.ts.map +1 -0
  74. package/dist/observed-inject.js +405 -0
  75. package/dist/observed-inject.js.map +1 -0
  76. package/dist/passive-observer.d.ts +14 -0
  77. package/dist/passive-observer.d.ts.map +1 -1
  78. package/dist/passive-observer.js +35 -5
  79. package/dist/passive-observer.js.map +1 -1
  80. package/dist/peripheral-mapping.d.ts +65 -0
  81. package/dist/peripheral-mapping.d.ts.map +1 -0
  82. package/dist/peripheral-mapping.js +115 -0
  83. package/dist/peripheral-mapping.js.map +1 -0
  84. package/dist/python-ble-runtime.d.ts +62 -0
  85. package/dist/python-ble-runtime.d.ts.map +1 -0
  86. package/dist/python-ble-runtime.js +215 -0
  87. package/dist/python-ble-runtime.js.map +1 -0
  88. package/dist/session-aggregator.d.ts +4 -1
  89. package/dist/session-aggregator.d.ts.map +1 -1
  90. package/dist/session-aggregator.js +5 -5
  91. package/dist/session-aggregator.js.map +1 -1
  92. package/dist/session-id-resolve.d.ts +17 -0
  93. package/dist/session-id-resolve.d.ts.map +1 -0
  94. package/dist/session-id-resolve.js +35 -0
  95. package/dist/session-id-resolve.js.map +1 -0
  96. package/dist/session-transcript-timeline.d.ts +7 -0
  97. package/dist/session-transcript-timeline.d.ts.map +1 -1
  98. package/dist/session-transcript-timeline.js +16 -6
  99. package/dist/session-transcript-timeline.js.map +1 -1
  100. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -1
  101. package/dist/timebox/timebox-daemon-sync.js +5 -20
  102. package/dist/timebox/timebox-daemon-sync.js.map +1 -1
  103. package/dist/timebox/timebox-discover.d.ts.map +1 -1
  104. package/dist/timebox/timebox-discover.js +6 -15
  105. package/dist/timebox/timebox-discover.js.map +1 -1
  106. package/dist/timeline-store.js +2 -2
  107. package/dist/timeline-store.js.map +1 -1
  108. package/dist/usage-event.d.ts.map +1 -1
  109. package/dist/usage-event.js +14 -7
  110. package/dist/usage-event.js.map +1 -1
  111. package/dist/voice.d.ts +13 -10
  112. package/dist/voice.d.ts.map +1 -1
  113. package/dist/voice.js +26 -272
  114. package/dist/voice.js.map +1 -1
  115. package/dist/ws-server.d.ts +14 -0
  116. package/dist/ws-server.d.ts.map +1 -1
  117. package/dist/ws-server.js +66 -1
  118. package/dist/ws-server.js.map +1 -1
  119. package/fm-helper/AgentDeckFMHelper.swift +303 -0
  120. package/package.json +8 -4
  121. package/python/requirements-ble.txt +5 -0
  122. package/src/idotmatrix/brightness.py +40 -0
  123. package/src/idotmatrix/scan.py +35 -0
  124. package/src/idotmatrix/sync.py +400 -0
  125. package/src/pysync/matrix_sync_common.py +89 -0
  126. package/src/timebox/scan_ble.py +52 -0
  127. package/src/timebox/sync_ble.py +394 -0
  128. package/dist/d200h/hid-protocol.d.ts +0 -62
  129. package/dist/d200h/hid-protocol.d.ts.map +0 -1
  130. package/dist/d200h/hid-protocol.js +0 -124
  131. package/dist/d200h/hid-protocol.js.map +0 -1
  132. package/dist/d200h/image-renderer.d.ts +0 -24
  133. package/dist/d200h/image-renderer.d.ts.map +0 -1
  134. package/dist/d200h/image-renderer.js +0 -321
  135. package/dist/d200h/image-renderer.js.map +0 -1
  136. package/dist/hook-migration.d.ts +0 -2
  137. package/dist/hook-migration.d.ts.map +0 -1
  138. package/dist/hook-migration.js +0 -104
  139. package/dist/hook-migration.js.map +0 -1
  140. package/dist/modules/d200h-module.d.ts +0 -65
  141. package/dist/modules/d200h-module.d.ts.map +0 -1
  142. package/dist/modules/d200h-module.js +0 -495
  143. package/dist/modules/d200h-module.js.map +0 -1
  144. package/dist/modules/d200h-renderer.d.ts +0 -7
  145. package/dist/modules/d200h-renderer.d.ts.map +0 -1
  146. package/dist/modules/d200h-renderer.js +0 -321
  147. package/dist/modules/d200h-renderer.js.map +0 -1
  148. package/dist/modules/trmnl-module.d.ts +0 -38
  149. package/dist/modules/trmnl-module.d.ts.map +0 -1
  150. package/dist/modules/trmnl-module.js +0 -111
  151. package/dist/modules/trmnl-module.js.map +0 -1
  152. package/dist/trmnl/byos-server.d.ts +0 -39
  153. package/dist/trmnl/byos-server.d.ts.map +0 -1
  154. package/dist/trmnl/byos-server.js +0 -301
  155. package/dist/trmnl/byos-server.js.map +0 -1
  156. package/dist/trmnl/frame-cache.d.ts +0 -37
  157. package/dist/trmnl/frame-cache.d.ts.map +0 -1
  158. package/dist/trmnl/frame-cache.js +0 -157
  159. package/dist/trmnl/frame-cache.js.map +0 -1
  160. package/dist/trmnl/image-renderer.d.ts +0 -27
  161. package/dist/trmnl/image-renderer.d.ts.map +0 -1
  162. package/dist/trmnl/image-renderer.js +0 -235
  163. package/dist/trmnl/image-renderer.js.map +0 -1
  164. package/dist/trmnl/trmnl-settings.d.ts +0 -96
  165. package/dist/trmnl/trmnl-settings.d.ts.map +0 -1
  166. package/dist/trmnl/trmnl-settings.js +0 -177
  167. package/dist/trmnl/trmnl-settings.js.map +0 -1
  168. package/dist/trmnl/trmnl-telemetry.d.ts +0 -47
  169. package/dist/trmnl/trmnl-telemetry.d.ts.map +0 -1
  170. package/dist/trmnl/trmnl-telemetry.js +0 -56
  171. package/dist/trmnl/trmnl-telemetry.js.map +0 -1
  172. package/dist/whisper-server-manager.d.ts +0 -19
  173. package/dist/whisper-server-manager.d.ts.map +0 -1
  174. package/dist/whisper-server-manager.js +0 -171
  175. package/dist/whisper-server-manager.js.map +0 -1
@@ -0,0 +1,400 @@
1
+ import asyncio
2
+ import os
3
+ import signal
4
+ import sys
5
+ import time
6
+ import hashlib
7
+ import tempfile
8
+ import urllib.request
9
+ import urllib.error
10
+ import io
11
+ import argparse
12
+ from PIL import Image as PilImage, ImageEnhance
13
+ from idotmatrix import ConnectionManager
14
+ from idotmatrix.modules.image import Image as IdmImage
15
+ from idotmatrix.modules.common import Common as IdmCommon
16
+
17
+ # Shared HTTP/dim plumbing with the Timebox client. We run from bridge/src/idotmatrix/,
18
+ # so add the sibling pysync/ dir before importing the common module.
19
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "pysync"))
20
+ from matrix_sync_common import ( # noqa: E402
21
+ DEFAULT_URL,
22
+ POLL_INTERVAL,
23
+ BRIDGE_GONE_EXIT_SEC,
24
+ fetch_display_state as _fetch_display_state_sync,
25
+ bridge_reachable as _bridge_reachable_sync,
26
+ resolve_display_brightness as _resolve_display_brightness_common,
27
+ )
28
+
29
+ OFFLINE_HASH = "offline"
30
+
31
+ async def fetch_frame(url: str) -> bytes:
32
+ """Fetch the current 32x32 BMP frame from the AgentDeck bridge."""
33
+ endpoint = f"{url.rstrip('/')}/pixoo/frame?size=32"
34
+
35
+ # We use urllib run_in_executor to avoid blocking the asyncio event loop
36
+ loop = asyncio.get_running_loop()
37
+ def _fetch():
38
+ with urllib.request.urlopen(endpoint, timeout=3.0) as response:
39
+ return response.read()
40
+
41
+ return await loop.run_in_executor(None, _fetch)
42
+
43
+ async def fetch_display_state(url: str):
44
+ """Fetch host display dim state from the daemon without blocking the loop."""
45
+ loop = asyncio.get_running_loop()
46
+ return await loop.run_in_executor(None, _fetch_display_state_sync, url)
47
+
48
+ async def bridge_reachable(url: str) -> bool:
49
+ """True when a daemon is answering the bridge URL (off-loop). Used to detect a
50
+ successor daemon so the orphaned OFFLINE farewell doesn't clobber its frame."""
51
+ loop = asyncio.get_running_loop()
52
+ return await loop.run_in_executor(None, _bridge_reachable_sync, url)
53
+
54
+ def resolve_display_brightness(display_state, normal_brightness: int) -> tuple[int, bool, str, str]:
55
+ """Return (hardware brightness, dimmed, signature, dim_mode) for the host state.
56
+ iDotMatrix firmware accepts 5-100 only; 5% is the practical off floor, which is why
57
+ mode 'off' additionally pushes a black frame (see run_sync)."""
58
+ return _resolve_display_brightness_common(
59
+ display_state, normal_brightness, off_floor=5, level_floor=5
60
+ )
61
+
62
+ def make_offline_image() -> PilImage.Image:
63
+ """Create a small local OFFLINE placeholder for bridge outages."""
64
+ img = PilImage.new("RGB", (32, 32), (8, 10, 14))
65
+ px = img.load()
66
+ for y in range(32):
67
+ for x in range(32):
68
+ if x in (0, 31) or y in (0, 31):
69
+ px[x, y] = (70, 76, 86)
70
+ # Compact block text that fits without font dependencies.
71
+ glyphs = {
72
+ "O": ["111", "101", "101", "101", "111"],
73
+ "F": ["111", "100", "110", "100", "100"],
74
+ "L": ["100", "100", "100", "100", "111"],
75
+ "I": ["111", "010", "010", "010", "111"],
76
+ "N": ["101", "111", "111", "111", "101"],
77
+ "E": ["111", "100", "110", "100", "111"],
78
+ }
79
+ def draw_text(text, x0, y0, color):
80
+ x = x0
81
+ for ch in text:
82
+ for yy, row in enumerate(glyphs[ch]):
83
+ for xx, bit in enumerate(row):
84
+ if bit == "1":
85
+ px[x + xx, y0 + yy] = color
86
+ x += 4
87
+ draw_text("OFF", 3, 8, (180, 65, 65))
88
+ draw_text("LINE", 3, 18, (130, 138, 150))
89
+ return img
90
+
91
+ async def upload_pil_image(idm_image: IdmImage, img: PilImage.Image) -> bool:
92
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
93
+ img.save(tmp, format="PNG")
94
+ tmp_path = tmp.name
95
+ try:
96
+ return bool(await idm_image.uploadUnprocessed(tmp_path))
97
+ finally:
98
+ if os.path.exists(tmp_path):
99
+ os.unlink(tmp_path)
100
+
101
+ async def upload_offline_frame(idm_image: IdmImage) -> bool:
102
+ return await upload_pil_image(idm_image, make_offline_image())
103
+
104
+ BLANK_HASH = "blank"
105
+
106
+ async def upload_blank_frame(idm_image: IdmImage) -> bool:
107
+ """Push an all-black 32x32 frame. Dim mode 'off' cannot be expressed through
108
+ hardware brightness on this panel — the firmware floor is 5% — so blanking the
109
+ pixels is what actually makes it read as asleep. Mirrors IDotMatrixModule.swift."""
110
+ return await upload_pil_image(idm_image, PilImage.new("RGB", (32, 32), (0, 0, 0)))
111
+
112
+ async def _interruptible_sleep(stop_event: asyncio.Event, secs: float) -> None:
113
+ """Sleep up to `secs`, but wake immediately if shutdown was requested."""
114
+ try:
115
+ await asyncio.wait_for(stop_event.wait(), timeout=secs)
116
+ except asyncio.TimeoutError:
117
+ pass
118
+
119
+ # iDotMatrix software brightness boost canonical = 1.22 — keep in sync:
120
+ # idotmatrix-daemon-sync.ts (--boost), sync.py (this default), IDotMatrixModule.swift.
121
+ async def run_sync(address: str, url: str, brightness: int = 100, boost: float = 1.22):
122
+ print(f"Initializing iDotMatrix Synchronization...")
123
+ print(f"Target Device BLE Address: {address}")
124
+ print(f"AgentDeck Bridge API URL: {url}")
125
+ print(f"Initial Hardware Brightness: {brightness}%")
126
+ print(f"Software Brightness Boost: {boost}x")
127
+
128
+ manager = ConnectionManager()
129
+
130
+ idm_image = IdmImage()
131
+ idm_image.conn = manager
132
+
133
+ idm_common = IdmCommon()
134
+ idm_common.conn = manager
135
+
136
+ last_hash = ""
137
+ connected = False
138
+ last_brightness_assert = 0.0
139
+ last_display_signature = ""
140
+ current_hw_brightness = brightness
141
+ display_dimmed = False
142
+ last_bridge_ok = time.monotonic()
143
+
144
+ # Graceful shutdown: when the daemon (or a user) stops us, we must leave the
145
+ # panel in a clean state — a stateful BLE display otherwise freezes on the
146
+ # last dashboard frame forever. Trap SIGTERM/SIGINT, break the loop, and push
147
+ # the OFFLINE frame before disconnecting (mirrors the Pixoo/D200H teardown
148
+ # invariant). add_signal_handler isn't available on all platforms; fall back
149
+ # to a classic handler that still requests a clean stop (never ignore the
150
+ # signal — that would strand the panel on its last frame).
151
+ stop_event = asyncio.Event()
152
+ # Why we're stopping — decides the farewell. 'signal' = clean daemon shutdown
153
+ # (no successor → paint OFFLINE). 'orphan' = parent died (a successor daemon may
154
+ # have taken over → don't clobber its frame). 'bridge_gone' = nobody home.
155
+ exit_reason = {"v": None}
156
+ loop = asyncio.get_running_loop()
157
+ def _request_stop() -> None:
158
+ exit_reason["v"] = "signal"
159
+ stop_event.set()
160
+ for _sig in (signal.SIGTERM, signal.SIGINT):
161
+ try:
162
+ loop.add_signal_handler(_sig, _request_stop)
163
+ except (NotImplementedError, RuntimeError):
164
+ signal.signal(_sig, lambda *_: loop.call_soon_threadsafe(_request_stop))
165
+
166
+ while not stop_event.is_set():
167
+ # Self-terminate when orphaned. If the daemon that spawned us died
168
+ # (crash / SIGKILL / sleep-kill / launchd), our parent reparents to
169
+ # PID 1 (launchd/init). A stateful BLE panel can't detect a dropped
170
+ # link, so without this we'd loop forever holding the single-central
171
+ # BLE connection — blocking the next daemon's fresh sync and leaving a
172
+ # zombie behind (observed: multiple orphaned sync clients across days).
173
+ # Break the loop so the teardown below paints OFFLINE, then exit.
174
+ if os.getppid() == 1:
175
+ print("Parent daemon gone (orphaned) — shutting down iDotMatrix sync.")
176
+ exit_reason["v"] = "orphan"
177
+ stop_event.set()
178
+ break
179
+ # Belt-and-suspenders: if the bridge has been unreachable for a sustained
180
+ # window (parent alive but wedged, or getppid is unreliable), also exit.
181
+ if time.monotonic() - last_bridge_ok > BRIDGE_GONE_EXIT_SEC:
182
+ print(f"Bridge unreachable >{int(BRIDGE_GONE_EXIT_SEC)}s — shutting down iDotMatrix sync.")
183
+ exit_reason["v"] = "bridge_gone"
184
+ stop_event.set()
185
+ break
186
+ try:
187
+ # 1. Ensure bluetooth connection
188
+ if not connected:
189
+ print(f"Connecting to iDotMatrix ({address})...")
190
+ await manager.connectByAddress(address)
191
+ print("Connected to Bluetooth device!")
192
+
193
+ # Settle: the panel drops commands sent too soon after the GATT
194
+ # link comes up. Without this the first setBrightness is lost on a
195
+ # reconnect — the device shows frames (setMode lands later) but
196
+ # stays dark. A short beat before/between control commands fixes it.
197
+ await _interruptible_sleep(stop_event, 0.4)
198
+
199
+ try:
200
+ display_state = await fetch_display_state(url)
201
+ last_bridge_ok = time.monotonic()
202
+ current_hw_brightness, display_dimmed, last_display_signature, dim_mode = resolve_display_brightness(display_state, brightness)
203
+ except Exception:
204
+ current_hw_brightness = brightness
205
+ display_dimmed = False
206
+ last_display_signature = ""
207
+ dim_mode = "off"
208
+
209
+ # Set initial hardware brightness
210
+ if current_hw_brightness:
211
+ print(f"Setting hardware brightness to {current_hw_brightness}%...")
212
+ await idm_common.setBrightness(current_hw_brightness)
213
+ await _interruptible_sleep(stop_event, 0.1)
214
+
215
+ # Enter DIY drawing mode (mode 1)
216
+ print("Entering DIY drawing mode...")
217
+ await idm_image.setMode(1)
218
+ await _interruptible_sleep(stop_event, 0.1)
219
+ connected = True
220
+ last_hash = "" # Force immediate push of first frame after reconnect
221
+ last_brightness_assert = time.monotonic()
222
+
223
+ # 1b. Detect a dropped BLE link. We write WITHOUT response, so a
224
+ # silent disconnect (device sleep, brownout reboot, RF glitch) never
225
+ # raises on write — without this check sync.py "pushes" into the void
226
+ # forever and the panel freezes on the last frame it actually got.
227
+ # bleak's is_connected reflects the real OS-level link state.
228
+ if manager.client is None or not manager.client.is_connected:
229
+ print("BLE link lost — reconnecting...")
230
+ connected = False
231
+ last_hash = ""
232
+ await _interruptible_sleep(stop_event, 1.0)
233
+ continue
234
+
235
+ # 1c. Apply host display sleep/wake dimming. The daemon exposes the
236
+ # same display_state that Pixoo/D200H/ESP32 receive over WS/serial.
237
+ try:
238
+ display_state = await fetch_display_state(url)
239
+ # A successful display-state fetch proves the bridge is alive.
240
+ # Without this the dimmed path below (which skips the frame
241
+ # fetch) starves the bridge-gone watchdog and the sync exits
242
+ # cleanly every BRIDGE_GONE_EXIT_SEC while the host display
243
+ # sleeps — the daemon then respawns it forever at 60s cadence.
244
+ last_bridge_ok = time.monotonic()
245
+ target_brightness, target_dimmed, signature, target_mode = resolve_display_brightness(display_state, brightness)
246
+ if signature != last_display_signature or target_brightness != current_hw_brightness:
247
+ print(f"Host display {'off' if target_dimmed else 'on'} — setting hardware brightness to {target_brightness}%")
248
+ await idm_common.setBrightness(target_brightness)
249
+ current_hw_brightness = target_brightness
250
+ display_dimmed = target_dimmed
251
+ dim_mode = target_mode
252
+ last_display_signature = signature
253
+ last_brightness_assert = time.monotonic()
254
+ if display_dimmed and dim_mode == "off":
255
+ # 5% is the firmware floor, so blank the pixels to make the
256
+ # panel read as asleep instead of frozen on the last frame.
257
+ if await upload_blank_frame(idm_image):
258
+ last_hash = BLANK_HASH
259
+ elif not display_dimmed:
260
+ # Force a repaint over the blank/dim frame on wake — the hash
261
+ # dedup below would otherwise swallow an unchanged dashboard.
262
+ last_hash = None
263
+ except Exception:
264
+ # Older/session-only bridges do not expose /display-state; keep
265
+ # the configured brightness rather than failing sync.
266
+ pass
267
+
268
+ # 1d. Periodically re-assert hardware brightness. The panel can dim
269
+ # itself (idle auto-dim, brownout reboot mid-session) while the BLE
270
+ # link stays up, so a one-shot setBrightness on connect isn't enough —
271
+ # re-send it so the display doesn't silently go dark.
272
+ if current_hw_brightness and time.monotonic() - last_brightness_assert > 60:
273
+ await idm_common.setBrightness(current_hw_brightness)
274
+ last_brightness_assert = time.monotonic()
275
+
276
+ if display_dimmed:
277
+ await _interruptible_sleep(stop_event, POLL_INTERVAL)
278
+ continue
279
+
280
+ # 2. Fetch frame from AgentDeck Bridge
281
+ try:
282
+ frame_data = await fetch_frame(url)
283
+ last_bridge_ok = time.monotonic()
284
+ except urllib.error.URLError as ue:
285
+ print(f"Bridge API offline or unreachable (GET /pixoo/frame): {ue.reason}")
286
+ if connected and last_hash != OFFLINE_HASH:
287
+ print("Sending local OFFLINE frame to iDotMatrix...")
288
+ if await upload_offline_frame(idm_image):
289
+ last_hash = OFFLINE_HASH
290
+ await _interruptible_sleep(stop_event, 5.0)
291
+ continue
292
+ except Exception as fe:
293
+ print(f"Failed to fetch frame from bridge: {fe}")
294
+ if connected and last_hash != OFFLINE_HASH:
295
+ print("Sending local OFFLINE frame to iDotMatrix...")
296
+ if await upload_offline_frame(idm_image):
297
+ last_hash = OFFLINE_HASH
298
+ await _interruptible_sleep(stop_event, 3.0)
299
+ continue
300
+
301
+ # 3. Check if frame has changed
302
+ current_hash = hashlib.sha256(frame_data).hexdigest()
303
+ if current_hash == last_hash:
304
+ # Frame didn't change, skip BLE transmission to save battery and bandwidth
305
+ await _interruptible_sleep(stop_event, POLL_INTERVAL)
306
+ continue
307
+
308
+ print(f"New frame detected (hash: {current_hash[:8]}). Enhancing and sending to display...")
309
+
310
+ # 4. Pillow Image Enhancement (Brightness & Contrast Boost)
311
+ # Load 32x32 BMP data
312
+ img = PilImage.open(io.BytesIO(frame_data))
313
+ if img.size != (32, 32):
314
+ try:
315
+ resample = PilImage.Resampling.BOX
316
+ except AttributeError:
317
+ resample = PilImage.BOX
318
+ img = img.resize((32, 32), resample)
319
+
320
+ # Apply Software Brightness Boost if set
321
+ if boost != 1.0:
322
+ bright_enhancer = ImageEnhance.Brightness(img)
323
+ img = bright_enhancer.enhance(boost)
324
+
325
+ # Restrained contrast preserves official-mark holes and edge
326
+ # coverage instead of clipping the small glyph into a blob.
327
+ contrast_enhancer = ImageEnhance.Contrast(img)
328
+ img = contrast_enhancer.enhance(1.08)
329
+
330
+ # Use uploadUnprocessed because we already resized and enhanced the image
331
+ res = await upload_pil_image(idm_image, img)
332
+ if res:
333
+ last_hash = current_hash
334
+ print("Frame uploaded successfully.")
335
+ else:
336
+ print("Failed to upload frame (uploadUnprocessed returned False).")
337
+
338
+ await _interruptible_sleep(stop_event, POLL_INTERVAL)
339
+
340
+ except asyncio.CancelledError:
341
+ print("Sync task cancelled.")
342
+ break
343
+ except Exception as e:
344
+ print(f"Error during loop: {e}")
345
+ print("Resetting bluetooth connection...")
346
+ connected = False
347
+ try:
348
+ await manager.disconnect()
349
+ except Exception:
350
+ pass
351
+ await _interruptible_sleep(stop_event, 3.0)
352
+
353
+ # Loop exited (shutdown requested). Leave the panel showing OFFLINE instead
354
+ # of a frozen dashboard frame, then drop the BLE link.
355
+ #
356
+ # EXCEPT when a successor daemon has already taken over: our parent died
357
+ # abruptly (orphan) but the bridge is answering again, so a new daemon
358
+ # restarted and is repainting. Painting OFFLINE here would clobber its fresh
359
+ # frame and the panel would sit on OFFLINE until it next changes / reconnects.
360
+ # We still disconnect cleanly (freeing the single-central link for the
361
+ # successor) — only the OFFLINE paint is suppressed.
362
+ successor_took_over = exit_reason["v"] == "orphan" and await bridge_reachable(url)
363
+ if connected:
364
+ if successor_took_over:
365
+ print("Successor daemon detected — skipping OFFLINE farewell (it will repaint).")
366
+ else:
367
+ print("Shutting down — sending OFFLINE frame to iDotMatrix...")
368
+ try:
369
+ await upload_offline_frame(idm_image)
370
+ # Let the final frame transmit before we drop the BLE link below —
371
+ # otherwise the disconnect races the in-flight write and the panel
372
+ # freezes on its last dashboard frame instead of showing OFFLINE.
373
+ await asyncio.sleep(0.5)
374
+ print(" OFFLINE frame sent.")
375
+ except Exception as e:
376
+ print(f" offline frame push failed: {e}")
377
+ try:
378
+ await manager.disconnect()
379
+ except Exception:
380
+ pass
381
+
382
+ def main():
383
+ parser = argparse.ArgumentParser(description="AgentDeck iDotMatrix Sync Client")
384
+ parser.add_argument("-a", "--address", required=True, help="BLE MAC/UUID Address of the iDotMatrix device")
385
+ parser.add_argument("-u", "--url", default=DEFAULT_URL, help=f"AgentDeck Bridge URL (default: {DEFAULT_URL})")
386
+ parser.add_argument("-b", "--brightness", type=int, default=100, help="Initial hardware brightness percent (5-100, default: 100)")
387
+ parser.add_argument("--boost", type=float, default=1.22, help="Software brightness boost factor (default: 1.22)")
388
+ args = parser.parse_args()
389
+
390
+ if args.brightness not in range(5, 101):
391
+ print("ERROR: Brightness must be between 5 and 100 percent.")
392
+ sys.exit(1)
393
+
394
+ try:
395
+ asyncio.run(run_sync(args.address, args.url, args.brightness, args.boost))
396
+ except KeyboardInterrupt:
397
+ print("\nExiting iDotMatrix Sync Client. Goodbye!")
398
+
399
+ if __name__ == "__main__":
400
+ main()
@@ -0,0 +1,89 @@
1
+ """Shared plumbing for AgentDeck BLE LED-matrix sync clients (iDotMatrix, Timebox Mini).
2
+
3
+ Both clients poll the same daemon HTTP surface (`/pixoo/frame`, `/display-state`), apply
4
+ the same host-display dim resolution, and self-exit when orphaned. Only the genuinely
5
+ identical plumbing lives here — the device-specific bits (BLE transport, frame encoding,
6
+ offline/farewell handling, and the dim FLOORS) stay in each client.
7
+
8
+ Imported by sibling scripts via a sys.path insert (they run from bridge/src/<device>/),
9
+ so this module must stay dependency-free beyond the stdlib.
10
+ """
11
+
12
+ import json
13
+ import urllib.request
14
+
15
+ DEFAULT_URL = "http://127.0.0.1:9120"
16
+ POLL_INTERVAL = 1.5 # seconds; balanced for BLE bandwidth
17
+ # If the bridge stays unreachable this long, the daemon that spawned us is gone
18
+ # (crash / SIGKILL / sleep-kill / launchd) — the client should exit cleanly after
19
+ # leaving the panel in a safe state instead of looping forever as an orphan that
20
+ # holds the single-central BLE link hostage.
21
+ BRIDGE_GONE_EXIT_SEC = 30.0
22
+
23
+
24
+ def http_get_bytes(url: str, timeout: float = 3.0) -> bytes:
25
+ """Blocking GET returning the raw body (frame bytes)."""
26
+ with urllib.request.urlopen(url, timeout=timeout) as response:
27
+ return response.read()
28
+
29
+
30
+ def fetch_display_state(url: str, timeout: float = 1.0):
31
+ """Fetch host display dim state from the AgentDeck daemon (blocking)."""
32
+ endpoint = f"{url.rstrip('/')}/display-state"
33
+ with urllib.request.urlopen(endpoint, timeout=timeout) as response:
34
+ return json.loads(response.read().decode("utf-8"))
35
+
36
+
37
+ def bridge_reachable(url: str, timeout: float = 1.0) -> bool:
38
+ """True when *some* daemon is answering on the bridge URL right now.
39
+
40
+ Used to detect a SUCCESSOR daemon: when our parent daemon died abruptly and a
41
+ new one restarted (launchd KeepAlive) and re-bound the port, the orphaned sync
42
+ client must NOT paint its farewell/OFFLINE frame — doing so would clobber the
43
+ frame the successor just drew and leave the panel stuck until a power-cycle. An
44
+ HTTP error status still counts as reachable (a server answered)."""
45
+ from urllib.error import HTTPError
46
+ try:
47
+ with urllib.request.urlopen(f"{url.rstrip('/')}/display-state", timeout=timeout):
48
+ return True
49
+ except HTTPError:
50
+ return True
51
+ except Exception:
52
+ return False
53
+
54
+
55
+ def resolve_display_brightness(display_state, normal_brightness, *, off_floor, level_floor):
56
+ """Return (effective brightness, dimmed, signature, dim_mode) for the host state.
57
+
58
+ `off_floor` — brightness used when the display is off and dim mode is 'off'.
59
+ `level_floor` — minimum the configured dim 'level' is clamped to.
60
+
61
+ iDotMatrix firmware accepts 5-100 (floors = 5); the Timebox bakes brightness into
62
+ the encoded frame and 0 == a fully blank panel (floors = 0). The signature lets the
63
+ caller detect host-state transitions and force a re-encode/re-push at the new level.
64
+
65
+ `dim_mode` ('off' | 'min') is returned because a brightness floor alone cannot
66
+ express "dark" on every panel: the Timebox reaches black at 0, but the iDotMatrix
67
+ floor is 5, so that caller has to push an explicitly black frame instead. Callers
68
+ must branch on the mode rather than inferring it from the brightness value —
69
+ level 5 in 'min' mode is indistinguishable from the iDotMatrix 'off' floor.
70
+ """
71
+ if not isinstance(display_state, dict):
72
+ return normal_brightness, False, f"on|true|off|10|{normal_brightness}", "off"
73
+
74
+ display_on = bool(display_state.get("displayOn", True))
75
+ dim = display_state.get("dim") if isinstance(display_state.get("dim"), dict) else {}
76
+ dim_enabled = dim.get("enabled", True)
77
+ if not isinstance(dim_enabled, bool):
78
+ dim_enabled = True
79
+ dim_mode = "min" if dim.get("mode") == "min" else "off"
80
+ try:
81
+ dim_level = int(dim.get("level", 10))
82
+ except (TypeError, ValueError):
83
+ dim_level = 10
84
+ dim_level = max(level_floor, min(100, dim_level))
85
+ signature = f"{display_on}|{dim_enabled}|{dim_mode}|{dim_level}|{normal_brightness}"
86
+
87
+ if display_on or not dim_enabled:
88
+ return normal_brightness, False, signature, dim_mode
89
+ return (dim_level if dim_mode == "min" else off_floor), True, signature, dim_mode
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """Scan for Divoom Timebox Mini (BLE variant) peripherals.
3
+
4
+ The BLE-variant Timebox Mini advertises its 11x11 LED screen as a BLE
5
+ peripheral named "TimeBox-mini-light" (the Classic audio endpoint shows up
6
+ separately as "TimeBox-mini-audio"). We surface every discovered peripheral but
7
+ flag and sort the Timebox light endpoints to the top, then print JSON so the
8
+ calling Node CLI can parse it. Mirrors bridge/src/idotmatrix/scan.py.
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import sys
14
+
15
+ from bleak import BleakScanner
16
+
17
+ # Advertised name of the BLE screen endpoint (case-insensitive contains match).
18
+ TIMEBOX_NAME = "timebox-mini-light"
19
+
20
+
21
+ def _is_timebox(name: str) -> bool:
22
+ return TIMEBOX_NAME in name.lower()
23
+
24
+
25
+ async def scan() -> None:
26
+ devices = await BleakScanner.discover(timeout=5.0, return_adv=True)
27
+
28
+ results = []
29
+ for address, (device, adv) in devices.items():
30
+ name = device.name or (adv.local_name if adv else None) or "Unknown"
31
+ results.append({
32
+ "name": name,
33
+ "address": address,
34
+ "rssi": adv.rssi if adv else None,
35
+ "is_timebox": _is_timebox(name),
36
+ })
37
+
38
+ # Timebox light endpoints first, then strongest signal.
39
+ results.sort(key=lambda x: (x["is_timebox"], x["rssi"] or -999), reverse=True)
40
+ print(json.dumps(results))
41
+
42
+
43
+ def main() -> None:
44
+ try:
45
+ asyncio.run(scan())
46
+ except Exception as e: # noqa: BLE001 — surface as JSON for the CLI
47
+ print(json.dumps({"error": str(e)}))
48
+ sys.exit(1)
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()