@agentdeck/bridge 1.0.3 → 1.0.5

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 (215) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +3 -0
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adb-reverse.d.ts.map +1 -1
  6. package/dist/adb-reverse.js +4 -2
  7. package/dist/adb-reverse.js.map +1 -1
  8. package/dist/apme/collector.js +2 -2
  9. package/dist/apme/collector.js.map +1 -1
  10. package/dist/apme/outcome.js +2 -2
  11. package/dist/apme/outcome.js.map +1 -1
  12. package/dist/apme/runner.js +3 -3
  13. package/dist/apme/runner.js.map +1 -1
  14. package/dist/ble-sync-spawn.js +1 -1
  15. package/dist/ble-sync-spawn.js.map +1 -1
  16. package/dist/calendar.d.ts +82 -0
  17. package/dist/calendar.d.ts.map +1 -0
  18. package/dist/calendar.js +324 -0
  19. package/dist/calendar.js.map +1 -0
  20. package/dist/card-feed.d.ts +63 -5
  21. package/dist/card-feed.d.ts.map +1 -1
  22. package/dist/card-feed.js +218 -14
  23. package/dist/card-feed.js.map +1 -1
  24. package/dist/card-modules.d.ts +4 -0
  25. package/dist/card-modules.d.ts.map +1 -1
  26. package/dist/card-modules.js +5 -3
  27. package/dist/card-modules.js.map +1 -1
  28. package/dist/check-deps.js +4 -4
  29. package/dist/check-deps.js.map +1 -1
  30. package/dist/cli.d.ts +35 -0
  31. package/dist/cli.d.ts.map +1 -1
  32. package/dist/cli.js +406 -76
  33. package/dist/cli.js.map +1 -1
  34. package/dist/codex-otel.d.ts +89 -0
  35. package/dist/codex-otel.d.ts.map +1 -0
  36. package/dist/codex-otel.js +487 -0
  37. package/dist/codex-otel.js.map +1 -0
  38. package/dist/codex-rate-limits.d.ts.map +1 -1
  39. package/dist/codex-rate-limits.js +15 -3
  40. package/dist/codex-rate-limits.js.map +1 -1
  41. package/dist/daemon-server.d.ts +4 -0
  42. package/dist/daemon-server.d.ts.map +1 -1
  43. package/dist/daemon-server.js +797 -54
  44. package/dist/daemon-server.js.map +1 -1
  45. package/dist/daemon-ws-client.d.ts +108 -24
  46. package/dist/daemon-ws-client.d.ts.map +1 -1
  47. package/dist/daemon-ws-client.js +175 -39
  48. package/dist/daemon-ws-client.js.map +1 -1
  49. package/dist/daemon.js +1 -1
  50. package/dist/device-voice-reply.d.ts.map +1 -1
  51. package/dist/device-voice-reply.js +15 -3
  52. package/dist/device-voice-reply.js.map +1 -1
  53. package/dist/device-voice.d.ts +37 -0
  54. package/dist/device-voice.d.ts.map +1 -1
  55. package/dist/device-voice.js +130 -1
  56. package/dist/device-voice.js.map +1 -1
  57. package/dist/diag-analyzer.d.ts.map +1 -1
  58. package/dist/diag-analyzer.js +1 -0
  59. package/dist/diag-analyzer.js.map +1 -1
  60. package/dist/display-monitor.d.ts.map +1 -1
  61. package/dist/display-monitor.js +1 -0
  62. package/dist/display-monitor.js.map +1 -1
  63. package/dist/esp32-serial.d.ts +4 -0
  64. package/dist/esp32-serial.d.ts.map +1 -1
  65. package/dist/esp32-serial.js +6 -3
  66. package/dist/esp32-serial.js.map +1 -1
  67. package/dist/foundation-models-helper.d.ts +23 -0
  68. package/dist/foundation-models-helper.d.ts.map +1 -1
  69. package/dist/foundation-models-helper.js +32 -0
  70. package/dist/foundation-models-helper.js.map +1 -1
  71. package/dist/gateway-probe.js +1 -1
  72. package/dist/gateway-probe.js.map +1 -1
  73. package/dist/glance-frame.d.ts +64 -0
  74. package/dist/glance-frame.d.ts.map +1 -0
  75. package/dist/glance-frame.js +364 -0
  76. package/dist/glance-frame.js.map +1 -0
  77. package/dist/hook-codex-sessions.d.ts +40 -0
  78. package/dist/hook-codex-sessions.d.ts.map +1 -0
  79. package/dist/hook-codex-sessions.js +174 -0
  80. package/dist/hook-codex-sessions.js.map +1 -0
  81. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -1
  82. package/dist/idotmatrix/idotmatrix-daemon-sync.js +5 -18
  83. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
  84. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -1
  85. package/dist/idotmatrix/idotmatrix-discover.js +12 -17
  86. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
  87. package/dist/idotmatrix/idotmatrix-settings.d.ts +15 -0
  88. package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -1
  89. package/dist/idotmatrix/idotmatrix-settings.js +21 -0
  90. package/dist/idotmatrix/idotmatrix-settings.js.map +1 -1
  91. package/dist/index.d.ts +7 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +88 -7
  94. package/dist/index.js.map +1 -1
  95. package/dist/linux-service.d.ts +52 -0
  96. package/dist/linux-service.d.ts.map +1 -0
  97. package/dist/linux-service.js +206 -0
  98. package/dist/linux-service.js.map +1 -0
  99. package/dist/logger.d.ts +6 -0
  100. package/dist/logger.d.ts.map +1 -1
  101. package/dist/logger.js +15 -0
  102. package/dist/logger.js.map +1 -1
  103. package/dist/mdns-discover.d.ts +19 -0
  104. package/dist/mdns-discover.d.ts.map +1 -0
  105. package/dist/mdns-discover.js +109 -0
  106. package/dist/mdns-discover.js.map +1 -0
  107. package/dist/model-catalog.d.ts.map +1 -1
  108. package/dist/model-catalog.js +1 -0
  109. package/dist/model-catalog.js.map +1 -1
  110. package/dist/modules/adb-module.js +1 -1
  111. package/dist/modules/adb-module.js.map +1 -1
  112. package/dist/output-parser.d.ts +9 -0
  113. package/dist/output-parser.d.ts.map +1 -1
  114. package/dist/output-parser.js +67 -16
  115. package/dist/output-parser.js.map +1 -1
  116. package/dist/passive-observer.d.ts +57 -8
  117. package/dist/passive-observer.d.ts.map +1 -1
  118. package/dist/passive-observer.js +210 -74
  119. package/dist/passive-observer.js.map +1 -1
  120. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  121. package/dist/pixoo/pixoo-bridge.js +47 -6
  122. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  123. package/dist/pixoo/pixoo-renderer.d.ts +2 -1
  124. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  125. package/dist/pixoo/pixoo-renderer.js +137 -8
  126. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  127. package/dist/pocket-autonomy.d.ts +78 -0
  128. package/dist/pocket-autonomy.d.ts.map +1 -0
  129. package/dist/pocket-autonomy.js +529 -0
  130. package/dist/pocket-autonomy.js.map +1 -0
  131. package/dist/pty-manager.d.ts.map +1 -1
  132. package/dist/pty-manager.js +3 -2
  133. package/dist/pty-manager.js.map +1 -1
  134. package/dist/python-ble-runtime.d.ts +64 -0
  135. package/dist/python-ble-runtime.d.ts.map +1 -0
  136. package/dist/python-ble-runtime.js +216 -0
  137. package/dist/python-ble-runtime.js.map +1 -0
  138. package/dist/remote-sessions.d.ts +64 -0
  139. package/dist/remote-sessions.d.ts.map +1 -0
  140. package/dist/remote-sessions.js +81 -0
  141. package/dist/remote-sessions.js.map +1 -0
  142. package/dist/review-runner.js +2 -2
  143. package/dist/review-runner.js.map +1 -1
  144. package/dist/session-aggregator.d.ts +2 -0
  145. package/dist/session-aggregator.d.ts.map +1 -1
  146. package/dist/session-aggregator.js +6 -1
  147. package/dist/session-aggregator.js.map +1 -1
  148. package/dist/session-focus-relay.d.ts +47 -1
  149. package/dist/session-focus-relay.d.ts.map +1 -1
  150. package/dist/session-focus-relay.js +130 -15
  151. package/dist/session-focus-relay.js.map +1 -1
  152. package/dist/session-push-channel.d.ts +31 -0
  153. package/dist/session-push-channel.d.ts.map +1 -0
  154. package/dist/session-push-channel.js +94 -0
  155. package/dist/session-push-channel.js.map +1 -0
  156. package/dist/session-registry.d.ts +126 -0
  157. package/dist/session-registry.d.ts.map +1 -1
  158. package/dist/session-registry.js +180 -3
  159. package/dist/session-registry.js.map +1 -1
  160. package/dist/subagent-timeline.d.ts +34 -0
  161. package/dist/subagent-timeline.d.ts.map +1 -0
  162. package/dist/subagent-timeline.js +156 -0
  163. package/dist/subagent-timeline.js.map +1 -0
  164. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -1
  165. package/dist/timebox/timebox-daemon-sync.js +5 -20
  166. package/dist/timebox/timebox-daemon-sync.js.map +1 -1
  167. package/dist/timebox/timebox-discover.d.ts.map +1 -1
  168. package/dist/timebox/timebox-discover.js +7 -16
  169. package/dist/timebox/timebox-discover.js.map +1 -1
  170. package/dist/tui/gauge.d.ts +4 -2
  171. package/dist/tui/gauge.d.ts.map +1 -1
  172. package/dist/tui/gauge.js +8 -4
  173. package/dist/tui/gauge.js.map +1 -1
  174. package/dist/tui/renderer.d.ts.map +1 -1
  175. package/dist/tui/renderer.js +28 -0
  176. package/dist/tui/renderer.js.map +1 -1
  177. package/dist/types.d.ts +1 -1
  178. package/dist/types.d.ts.map +1 -1
  179. package/dist/types.js.map +1 -1
  180. package/dist/usage-api.d.ts +43 -0
  181. package/dist/usage-api.d.ts.map +1 -1
  182. package/dist/usage-api.js +114 -9
  183. package/dist/usage-api.js.map +1 -1
  184. package/dist/usage-event.d.ts +19 -0
  185. package/dist/usage-event.d.ts.map +1 -1
  186. package/dist/usage-event.js +35 -0
  187. package/dist/usage-event.js.map +1 -1
  188. package/dist/utils/project-name.d.ts.map +1 -1
  189. package/dist/utils/project-name.js +1 -0
  190. package/dist/utils/project-name.js.map +1 -1
  191. package/dist/version-check.d.ts.map +1 -1
  192. package/dist/version-check.js +3 -2
  193. package/dist/version-check.js.map +1 -1
  194. package/dist/voice-assistant.d.ts.map +1 -1
  195. package/dist/voice-assistant.js +2 -1
  196. package/dist/voice-assistant.js.map +1 -1
  197. package/dist/voice.d.ts.map +1 -1
  198. package/dist/voice.js +1 -0
  199. package/dist/voice.js.map +1 -1
  200. package/dist/weather.d.ts +72 -0
  201. package/dist/weather.d.ts.map +1 -0
  202. package/dist/weather.js +206 -0
  203. package/dist/weather.js.map +1 -0
  204. package/dist/windows-service.js +5 -5
  205. package/dist/windows-service.js.map +1 -1
  206. package/fm-helper/AgentDeckFMHelper.swift +200 -0
  207. package/package.json +20 -14
  208. package/python/requirements-ble.txt +5 -0
  209. package/src/idotmatrix/brightness.py +40 -0
  210. package/src/idotmatrix/identity_generated.py +50 -0
  211. package/src/idotmatrix/scan.py +55 -0
  212. package/src/idotmatrix/sync.py +400 -0
  213. package/src/pysync/matrix_sync_common.py +89 -0
  214. package/src/timebox/scan_ble.py +52 -0
  215. package/src/timebox/sync_ble.py +394 -0
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env python3
2
+ """Sync AgentDeck frames to a Divoom Timebox Mini (BLE variant) over BLE GATT.
3
+
4
+ Some Timebox Mini revisions expose the 11x11 LED screen via BLE GATT using an
5
+ ISSC transparent-UART service (49535343-...), NOT Bluetooth Classic SPP. The
6
+ device appears in macOS as "TimeBox-mini-light" (a BLE peripheral) and shares
7
+ its BD_ADDR with the Classic audio endpoint under "TimeBox-mini-audio".
8
+
9
+ This writer builds the Divoom static-image protocol packet and tunnels it
10
+ through BLE GATT writes to the transparent-UART TX characteristic. Requires the
11
+ `bleak` package in the venv.
12
+ """
13
+
14
+ import argparse
15
+ import asyncio
16
+ import hashlib
17
+ import io
18
+ import os
19
+ import signal
20
+ import sys
21
+ import time
22
+ import urllib.request
23
+ from typing import Iterable
24
+
25
+ from PIL import Image as PilImage, ImageEnhance
26
+
27
+ # Shared HTTP/dim plumbing with the iDotMatrix client. We run from bridge/src/timebox/,
28
+ # so add the sibling pysync/ dir before importing the common module.
29
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "pysync"))
30
+ from matrix_sync_common import ( # noqa: E402
31
+ DEFAULT_URL,
32
+ POLL_INTERVAL,
33
+ BRIDGE_GONE_EXIT_SEC,
34
+ fetch_display_state,
35
+ bridge_reachable,
36
+ resolve_display_brightness as _resolve_display_brightness_common,
37
+ )
38
+
39
+ RECONNECT_DELAY = 3.0
40
+ # Force a re-push of the current frame at least this often even when the content
41
+ # is unchanged. A stateful write-without-response panel gives no delivery ACK, so
42
+ # a frame lost to an RF glitch or a brief dual-writer overlap (daemon restart)
43
+ # would otherwise stick until the content changes or the device is power-cycled.
44
+ # The heartbeat makes any such loss self-heal within a few seconds.
45
+ HEARTBEAT_SEC = 8.0
46
+
47
+ TIMEBOX_W = 11
48
+ TIMEBOX_H = 11
49
+ STATIC_IMAGE_CMD_LEN = 0x00BD
50
+
51
+ # ISSC transparent-UART service characteristics (discovered on Timebox Mini BLE)
52
+ WRITE_CHAR = "49535343-8841-43f4-a8d4-ecbe34729bb3" # write + write-without-response
53
+ CHUNK_SIZE = 20 # safe ATT payload for write-without-response
54
+
55
+
56
+ def clamp_nibble(value: int) -> int:
57
+ return max(0, min(15, value))
58
+
59
+
60
+ def escape_message(data: Iterable[int]) -> bytes:
61
+ out = bytearray()
62
+ out.append(0x01)
63
+ for b in data:
64
+ if b in (0x01, 0x02, 0x03):
65
+ out.append(0x03)
66
+ out.append(b + 0x03)
67
+ else:
68
+ out.append(b)
69
+ out.append(0x02)
70
+ return bytes(out)
71
+
72
+
73
+ def build_static_image_packet(image_bytes: bytes) -> bytes:
74
+ cmd = bytes([
75
+ STATIC_IMAGE_CMD_LEN & 0xFF,
76
+ (STATIC_IMAGE_CMD_LEN >> 8) & 0xFF,
77
+ 0x44,
78
+ 0x00,
79
+ 0x0A,
80
+ 0x0A,
81
+ 0x04,
82
+ ]) + image_bytes
83
+ if len(cmd) != STATIC_IMAGE_CMD_LEN:
84
+ raise ValueError(f"command length {len(cmd)} != {STATIC_IMAGE_CMD_LEN}")
85
+ checksum = sum(cmd) & 0xFFFF
86
+ return escape_message(cmd + bytes([checksum & 0xFF, (checksum >> 8) & 0xFF]))
87
+
88
+
89
+ def encode_image_bright(img: PilImage.Image, brightness: int, gamma: float, sat: float, contrast: float) -> bytes:
90
+ """Encode the native 11x11 micro frame to a 182-byte Timebox payload.
91
+
92
+ The source is `size=11&layout=micro` — a bold hand-authored creature glyph
93
+ already drawn at the device resolution with final, device-tuned colors. So the
94
+ pipeline is WYSIWYG by default (gamma/sat/contrast = 1.0): only the 0-100
95
+ software `brightness` dim is applied, then 4-bit quantization. The gamma/sat/
96
+ contrast args remain for manual tuning, but default to identity.
97
+ """
98
+ img = img.convert("RGB").resize((TIMEBOX_W, TIMEBOX_H), PilImage.Resampling.BOX)
99
+ if brightness <= 0:
100
+ return bytes(182)
101
+
102
+ if gamma != 1.0:
103
+ lut: list[int] = []
104
+ for _c in range(3):
105
+ lut.extend(min(255, int(255 * ((i / 255.0) ** gamma))) for i in range(256))
106
+ img = img.point(lut)
107
+ if sat != 1.0:
108
+ img = ImageEnhance.Color(img).enhance(sat)
109
+ if brightness != 100:
110
+ img = ImageEnhance.Brightness(img).enhance(brightness / 100.0)
111
+ if contrast != 1.0:
112
+ img = ImageEnhance.Contrast(img).enhance(contrast)
113
+
114
+ nibbles: list[int] = []
115
+ px = img.load()
116
+ for y in range(TIMEBOX_H):
117
+ for x in range(TIMEBOX_W):
118
+ r, g, b = px[x, y]
119
+ nibbles.extend([
120
+ clamp_nibble(round(r / 17)),
121
+ clamp_nibble(round(g / 17)),
122
+ clamp_nibble(round(b / 17)),
123
+ ])
124
+
125
+ out = bytearray()
126
+ it = iter(nibbles)
127
+ for low in it:
128
+ high = next(it, 0)
129
+ out.append(low | (high << 4))
130
+ if len(out) != 182:
131
+ raise ValueError(f"encoded Timebox image has {len(out)} bytes, expected 182")
132
+ return bytes(out)
133
+
134
+
135
+ def resolve_display_brightness(display_state, normal_brightness: int):
136
+ """Return (effective software brightness, dimmed, signature) for the host state.
137
+
138
+ The Timebox Mini has no hardware brightness command — brightness is baked into the
139
+ encoded frame by `encode_image_bright` (0 yields a blank frame). So the off floor is
140
+ 0 (a truly blank sleep frame) and the dim 'level' clamps down to 0 as well.
141
+ """
142
+ return _resolve_display_brightness_common(
143
+ display_state, normal_brightness, off_floor=0, level_floor=0
144
+ )
145
+
146
+
147
+ async def write_packet(client, packet: bytes) -> None:
148
+ """Chunked write-without-response to the transparent-UART TX characteristic."""
149
+ for i in range(0, len(packet), CHUNK_SIZE):
150
+ await client.write_gatt_char(WRITE_CHAR, packet[i:i + CHUNK_SIZE], response=False)
151
+
152
+
153
+ async def blank_panel(client) -> None:
154
+ """Push an all-black 11x11 farewell frame so the stateful LED panel doesn't
155
+ freeze on the last dashboard scene after we go away. The Timebox Mini has no
156
+ text resolution for an "OFFLINE" label (mirrors the Swift TimeboxModule, which
157
+ also blanks to 11x11 black; iDotMatrix/Pixoo can fit an OFFLINE glyph)."""
158
+ await write_packet(client, build_static_image_packet(bytes(182)))
159
+
160
+
161
+ async def push_micro_frame(client, url, brightness, gamma, sat, contrast, last_key, force=False) -> tuple[str, bool]:
162
+ """Fetch the native 11x11 micro frame and push it over BLE when its
163
+ content+brightness changed (or `force`). Returns (new dedup key, sent?).
164
+
165
+ `size=11&layout=micro` is a NATIVE 11x11 frame — a bold hand-authored creature
166
+ glyph on a status field, drawn pixel-for-pixel at the device resolution (no
167
+ downscale). The key mixes the source hash with `brightness` so a host display
168
+ sleep/wake (brightness change, same source frame) still forces a re-push.
169
+ """
170
+ frame_data = urllib.request.urlopen(
171
+ f"{url.rstrip('/')}/pixoo/frame?size=11&layout=micro", timeout=3.0
172
+ ).read()
173
+ key = f"{hashlib.sha256(frame_data).hexdigest()}|{brightness}"
174
+ if force or key != last_key:
175
+ img = PilImage.open(io.BytesIO(frame_data))
176
+ payload = encode_image_bright(img, brightness, gamma, sat, contrast)
177
+ await write_packet(client, build_static_image_packet(payload))
178
+ print(f"Frame sent ({key[:8]} @ {brightness}%)")
179
+ return key, True
180
+ return key, False
181
+
182
+
183
+ async def run(address: str, url: str, brightness: int, gamma: float, sat: float, contrast: float, once: bool = False) -> None:
184
+ print(f"Starting Timebox Mini BLE sync: {address} <- {url} brightness={brightness}% gamma={gamma}")
185
+ stop = asyncio.Event()
186
+ # Why we're stopping — decides the farewell. 'signal' = clean daemon shutdown
187
+ # (no successor → blank the panel). 'orphan' = parent died (a successor daemon
188
+ # may have taken over → don't clobber its frame). 'bridge_gone' = nobody home.
189
+ exit_reason = {"v": None}
190
+
191
+ def handle_stop(*_):
192
+ exit_reason["v"] = "signal"
193
+ stop.set()
194
+
195
+ loop = asyncio.get_running_loop()
196
+ for sig in (signal.SIGINT, signal.SIGTERM):
197
+ try:
198
+ loop.add_signal_handler(sig, handle_stop)
199
+ except (NotImplementedError, RuntimeError):
200
+ # Fall back to a classic handler that still requests a clean stop.
201
+ # The previous fallback installed a no-op handler that IGNORED
202
+ # SIGTERM — that stranded the process (the daemon's SIGTERM on
203
+ # shutdown did nothing) and left orphaned sync clients running for
204
+ # days, each holding the BLE link. Never ignore the signal.
205
+ signal.signal(sig, lambda *_: loop.call_soon_threadsafe(handle_stop))
206
+
207
+ # Imported lazily so the module is usable for encoding tests without bleak.
208
+ from bleak import BleakClient
209
+
210
+ last_bridge_ok = time.monotonic()
211
+
212
+ def should_exit() -> bool:
213
+ """True when we've been orphaned (parent daemon gone) or the bridge has
214
+ been unreachable long enough that nobody will ever stop us. A stateful
215
+ BLE panel can't detect a dropped link, so an orphan would otherwise loop
216
+ forever holding the single-central connection."""
217
+ if os.getppid() == 1:
218
+ print("Parent daemon gone (orphaned) — shutting down Timebox sync.")
219
+ exit_reason["v"] = "orphan"
220
+ return True
221
+ if time.monotonic() - last_bridge_ok > BRIDGE_GONE_EXIT_SEC:
222
+ print(f"Bridge unreachable >{int(BRIDGE_GONE_EXIT_SEC)}s — shutting down Timebox sync.")
223
+ exit_reason["v"] = "bridge_gone"
224
+ return True
225
+ return False
226
+
227
+ while not stop.is_set():
228
+ if should_exit():
229
+ stop.set()
230
+ break
231
+ link_lost = asyncio.Event()
232
+
233
+ def on_disconnect(_client):
234
+ # Fired by bleak when the GATT link drops (e.g. the Timebox is powered
235
+ # off). A stateful BLE panel driven by write-without-response means our
236
+ # writes can silently "succeed" over a dead link, so this callback — not
237
+ # a write error — is the authoritative "reconnect now" signal. Without it
238
+ # the inner loop would spin forever on a dead link and the outer reconnect
239
+ # path (below) would never run.
240
+ print("BLE peripheral disconnected — will reconnect.", file=sys.stderr)
241
+ loop.call_soon_threadsafe(link_lost.set)
242
+
243
+ try:
244
+ print(f"Connecting BLE {address}...")
245
+ async with BleakClient(address, timeout=15.0, disconnected_callback=on_disconnect) as client:
246
+ print(f"BLE connected (MTU={client.mtu_size})")
247
+
248
+ last_key = ""
249
+ last_sent_at = time.monotonic()
250
+ current_brightness = brightness
251
+ display_dimmed = False
252
+ last_display_signature = ""
253
+ # Honor the host display dim state already at connect time so a
254
+ # reconnect while the screen is asleep comes up dim/blank, not bright.
255
+ try:
256
+ current_brightness, display_dimmed, last_display_signature, _mode = \
257
+ resolve_display_brightness(fetch_display_state(url), brightness)
258
+ last_bridge_ok = time.monotonic()
259
+ except Exception:
260
+ current_brightness, display_dimmed, last_display_signature = brightness, False, ""
261
+
262
+ while not stop.is_set():
263
+ if should_exit():
264
+ stop.set()
265
+ break
266
+ # 0. The link dropped (peripheral powered off / out of range):
267
+ # leave the `async with` so the outer loop reconnects by
268
+ # address (macOS bleak addresses are stable UUIDs). Checked
269
+ # every iteration because write-without-response never errors
270
+ # on a dead link — only this flag / is_connected reveals it.
271
+ if link_lost.is_set() or not client.is_connected:
272
+ print("BLE link lost — reconnecting.", file=sys.stderr)
273
+ break
274
+ # 1. Apply host display sleep/wake. The daemon exposes the same
275
+ # display_state Pixoo/iDotMatrix/ESP32 receive; older session-
276
+ # only bridges omit it (keep the configured brightness).
277
+ transitioned = False
278
+ try:
279
+ eff_b, dimmed, sig, _mode = resolve_display_brightness(fetch_display_state(url), brightness)
280
+ last_bridge_ok = time.monotonic()
281
+ if sig != last_display_signature or eff_b != current_brightness:
282
+ current_brightness, display_dimmed, last_display_signature = eff_b, dimmed, sig
283
+ transitioned = True
284
+ print(f"Host display {'asleep' if dimmed else 'awake'} — brightness {eff_b}%")
285
+ except Exception:
286
+ pass
287
+
288
+ # 2. While the host display is asleep, push one frame at the dim
289
+ # brightness on the transition (0 => blank sleep frame), then
290
+ # pause polling to save BLE bandwidth (mirrors iDotMatrix).
291
+ if display_dimmed:
292
+ if transitioned or once:
293
+ try:
294
+ last_key, sent = await push_micro_frame(
295
+ client, url, current_brightness, gamma, sat, contrast, last_key, force=True
296
+ )
297
+ if sent:
298
+ last_sent_at = time.monotonic()
299
+ last_bridge_ok = time.monotonic()
300
+ except Exception as e:
301
+ print(f"Dim-frame send error: {e}", file=sys.stderr)
302
+ if link_lost.is_set() or not client.is_connected:
303
+ break
304
+ if once:
305
+ return
306
+ try:
307
+ await asyncio.wait_for(stop.wait(), timeout=POLL_INTERVAL)
308
+ except asyncio.TimeoutError:
309
+ pass
310
+ continue
311
+
312
+ # 3. Normal streaming — push when content/brightness changed, or
313
+ # force a heartbeat re-push so a frame lost to an RF glitch or a
314
+ # brief dual-writer overlap (daemon restart) self-heals instead
315
+ # of sticking until the content changes or a power-cycle.
316
+ force_beat = (time.monotonic() - last_sent_at) >= HEARTBEAT_SEC
317
+ try:
318
+ last_key, sent = await push_micro_frame(
319
+ client, url, current_brightness, gamma, sat, contrast, last_key, force=force_beat
320
+ )
321
+ if sent:
322
+ last_sent_at = time.monotonic()
323
+ last_bridge_ok = time.monotonic()
324
+ except Exception as e:
325
+ print(f"Frame fetch/send error: {e}", file=sys.stderr)
326
+ # A write error after the link dropped must escalate to a
327
+ # reconnect; a transient HTTP fetch error (bridge blip) must
328
+ # not — keep streaming and let should_exit() handle a truly
329
+ # gone bridge.
330
+ if link_lost.is_set() or not client.is_connected:
331
+ break
332
+ if once:
333
+ return
334
+ try:
335
+ await asyncio.wait_for(stop.wait(), timeout=POLL_INTERVAL)
336
+ except asyncio.TimeoutError:
337
+ pass
338
+
339
+ # Inner loop exited. If we're shutting down (SIGTERM, orphaned, or
340
+ # bridge gone) and the link is still up, blank the panel before the
341
+ # `async with` drops BLE — otherwise the stateful LED panel freezes
342
+ # on the last dashboard frame forever (parity with iDotMatrix's
343
+ # OFFLINE farewell and the Swift TimeboxModule's 11x11 black blank).
344
+ #
345
+ # EXCEPT when a successor daemon has already taken over: our parent
346
+ # died abruptly (orphan) but the bridge is answering again, so a new
347
+ # daemon restarted and is repainting the panel. Blanking here would
348
+ # clobber its fresh frame and the panel would sit blank until a
349
+ # power-cycle — the exact failure this guard prevents.
350
+ successor_took_over = exit_reason["v"] == "orphan" and bridge_reachable(url)
351
+ if successor_took_over:
352
+ print("Successor daemon detected — skipping farewell blank (it will repaint).")
353
+ if stop.is_set() and client.is_connected and not successor_took_over:
354
+ try:
355
+ await blank_panel(client)
356
+ # blank_panel writes WITHOUT response — the await returns once
357
+ # the packet is queued to the OS, not once it's transmitted.
358
+ # The `async with` below drops the BLE link immediately on
359
+ # exit; without this beat the queued blank never goes over the
360
+ # air and the panel freezes on its last dashboard frame.
361
+ await asyncio.sleep(0.5)
362
+ print("Shutting down — blanked Timebox panel.")
363
+ except Exception as e:
364
+ print(f"Farewell blank failed: {e}", file=sys.stderr)
365
+ except Exception as e:
366
+ print(f"BLE connection error: {e}", file=sys.stderr)
367
+ if once:
368
+ raise
369
+ try:
370
+ await asyncio.wait_for(stop.wait(), timeout=RECONNECT_DELAY)
371
+ except asyncio.TimeoutError:
372
+ pass
373
+
374
+
375
+ def main() -> None:
376
+ parser = argparse.ArgumentParser(description="AgentDeck Timebox Mini BLE sync")
377
+ parser.add_argument("--address", required=True, help="BLE address/UUID of TimeBox-mini-light")
378
+ parser.add_argument("--url", default=DEFAULT_URL, help=f"AgentDeck bridge URL (default: {DEFAULT_URL})")
379
+ parser.add_argument("--brightness", type=int, default=100, help="Software brightness 0-100")
380
+ parser.add_argument("--gamma", type=float, default=1.0, help="Gamma (lower=brighter midtones; 1.0=off)")
381
+ parser.add_argument("--sat", type=float, default=1.0, help="Saturation multiplier (1.0=off)")
382
+ parser.add_argument("--contrast", type=float, default=1.0, help="Contrast multiplier (1.0=off)")
383
+ parser.add_argument("--once", action="store_true", help="Send one frame and exit")
384
+ args = parser.parse_args()
385
+
386
+ if not (0 <= args.brightness <= 100):
387
+ print("Brightness must be between 0 and 100.", file=sys.stderr)
388
+ sys.exit(1)
389
+
390
+ asyncio.run(run(args.address, args.url, args.brightness, args.gamma, args.sat, args.contrast, args.once))
391
+
392
+
393
+ if __name__ == "__main__":
394
+ main()