@agentdeck/bridge 1.0.3 → 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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +139 -69
- package/dist/cli.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.js +5 -18
- package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
- package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -1
- package/dist/idotmatrix/idotmatrix-discover.js +6 -14
- package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
- package/dist/python-ble-runtime.d.ts +62 -0
- package/dist/python-ble-runtime.d.ts.map +1 -0
- package/dist/python-ble-runtime.js +215 -0
- package/dist/python-ble-runtime.js.map +1 -0
- package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -1
- package/dist/timebox/timebox-daemon-sync.js +5 -20
- package/dist/timebox/timebox-daemon-sync.js.map +1 -1
- package/dist/timebox/timebox-discover.d.ts.map +1 -1
- package/dist/timebox/timebox-discover.js +6 -15
- package/dist/timebox/timebox-discover.js.map +1 -1
- package/package.json +8 -4
- package/python/requirements-ble.txt +5 -0
- package/src/idotmatrix/brightness.py +40 -0
- package/src/idotmatrix/scan.py +35 -0
- package/src/idotmatrix/sync.py +400 -0
- package/src/pysync/matrix_sync_common.py +89 -0
- package/src/timebox/scan_ble.py +52 -0
- 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()
|