@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.
- package/assets/fm-helper/agentdeck-fm-helper +0 -0
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +3 -0
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adb-reverse.d.ts.map +1 -1
- package/dist/adb-reverse.js +4 -2
- package/dist/adb-reverse.js.map +1 -1
- package/dist/apme/collector.js +2 -2
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/outcome.js +2 -2
- package/dist/apme/outcome.js.map +1 -1
- package/dist/apme/runner.js +3 -3
- package/dist/apme/runner.js.map +1 -1
- package/dist/ble-sync-spawn.js +1 -1
- package/dist/ble-sync-spawn.js.map +1 -1
- package/dist/calendar.d.ts +82 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +324 -0
- package/dist/calendar.js.map +1 -0
- package/dist/card-feed.d.ts +63 -5
- package/dist/card-feed.d.ts.map +1 -1
- package/dist/card-feed.js +218 -14
- package/dist/card-feed.js.map +1 -1
- package/dist/card-modules.d.ts +4 -0
- package/dist/card-modules.d.ts.map +1 -1
- package/dist/card-modules.js +5 -3
- package/dist/card-modules.js.map +1 -1
- package/dist/check-deps.js +4 -4
- package/dist/check-deps.js.map +1 -1
- package/dist/cli.d.ts +35 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +406 -76
- package/dist/cli.js.map +1 -1
- package/dist/codex-otel.d.ts +89 -0
- package/dist/codex-otel.d.ts.map +1 -0
- package/dist/codex-otel.js +487 -0
- package/dist/codex-otel.js.map +1 -0
- package/dist/codex-rate-limits.d.ts.map +1 -1
- package/dist/codex-rate-limits.js +15 -3
- package/dist/codex-rate-limits.js.map +1 -1
- package/dist/daemon-server.d.ts +4 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +797 -54
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon-ws-client.d.ts +108 -24
- package/dist/daemon-ws-client.d.ts.map +1 -1
- package/dist/daemon-ws-client.js +175 -39
- package/dist/daemon-ws-client.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/device-voice-reply.d.ts.map +1 -1
- package/dist/device-voice-reply.js +15 -3
- package/dist/device-voice-reply.js.map +1 -1
- package/dist/device-voice.d.ts +37 -0
- package/dist/device-voice.d.ts.map +1 -1
- package/dist/device-voice.js +130 -1
- package/dist/device-voice.js.map +1 -1
- package/dist/diag-analyzer.d.ts.map +1 -1
- package/dist/diag-analyzer.js +1 -0
- package/dist/diag-analyzer.js.map +1 -1
- package/dist/display-monitor.d.ts.map +1 -1
- package/dist/display-monitor.js +1 -0
- package/dist/display-monitor.js.map +1 -1
- package/dist/esp32-serial.d.ts +4 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +6 -3
- package/dist/esp32-serial.js.map +1 -1
- package/dist/foundation-models-helper.d.ts +23 -0
- package/dist/foundation-models-helper.d.ts.map +1 -1
- package/dist/foundation-models-helper.js +32 -0
- package/dist/foundation-models-helper.js.map +1 -1
- package/dist/gateway-probe.js +1 -1
- package/dist/gateway-probe.js.map +1 -1
- package/dist/glance-frame.d.ts +64 -0
- package/dist/glance-frame.d.ts.map +1 -0
- package/dist/glance-frame.js +364 -0
- package/dist/glance-frame.js.map +1 -0
- package/dist/hook-codex-sessions.d.ts +40 -0
- package/dist/hook-codex-sessions.d.ts.map +1 -0
- package/dist/hook-codex-sessions.js +174 -0
- package/dist/hook-codex-sessions.js.map +1 -0
- 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 +12 -17
- package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
- package/dist/idotmatrix/idotmatrix-settings.d.ts +15 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -1
- package/dist/idotmatrix/idotmatrix-settings.js +21 -0
- package/dist/idotmatrix/idotmatrix-settings.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -7
- package/dist/index.js.map +1 -1
- package/dist/linux-service.d.ts +52 -0
- package/dist/linux-service.d.ts.map +1 -0
- package/dist/linux-service.js +206 -0
- package/dist/linux-service.js.map +1 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -1
- package/dist/mdns-discover.d.ts +19 -0
- package/dist/mdns-discover.d.ts.map +1 -0
- package/dist/mdns-discover.js +109 -0
- package/dist/mdns-discover.js.map +1 -0
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +1 -0
- package/dist/model-catalog.js.map +1 -1
- package/dist/modules/adb-module.js +1 -1
- package/dist/modules/adb-module.js.map +1 -1
- package/dist/output-parser.d.ts +9 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +67 -16
- package/dist/output-parser.js.map +1 -1
- package/dist/passive-observer.d.ts +57 -8
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +210 -74
- package/dist/passive-observer.js.map +1 -1
- package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
- package/dist/pixoo/pixoo-bridge.js +47 -6
- package/dist/pixoo/pixoo-bridge.js.map +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts +2 -1
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +137 -8
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pocket-autonomy.d.ts +78 -0
- package/dist/pocket-autonomy.d.ts.map +1 -0
- package/dist/pocket-autonomy.js +529 -0
- package/dist/pocket-autonomy.js.map +1 -0
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +3 -2
- package/dist/pty-manager.js.map +1 -1
- package/dist/python-ble-runtime.d.ts +64 -0
- package/dist/python-ble-runtime.d.ts.map +1 -0
- package/dist/python-ble-runtime.js +216 -0
- package/dist/python-ble-runtime.js.map +1 -0
- package/dist/remote-sessions.d.ts +64 -0
- package/dist/remote-sessions.d.ts.map +1 -0
- package/dist/remote-sessions.js +81 -0
- package/dist/remote-sessions.js.map +1 -0
- package/dist/review-runner.js +2 -2
- package/dist/review-runner.js.map +1 -1
- package/dist/session-aggregator.d.ts +2 -0
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js +6 -1
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-focus-relay.d.ts +47 -1
- package/dist/session-focus-relay.d.ts.map +1 -1
- package/dist/session-focus-relay.js +130 -15
- package/dist/session-focus-relay.js.map +1 -1
- package/dist/session-push-channel.d.ts +31 -0
- package/dist/session-push-channel.d.ts.map +1 -0
- package/dist/session-push-channel.js +94 -0
- package/dist/session-push-channel.js.map +1 -0
- package/dist/session-registry.d.ts +126 -0
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +180 -3
- package/dist/session-registry.js.map +1 -1
- package/dist/subagent-timeline.d.ts +34 -0
- package/dist/subagent-timeline.d.ts.map +1 -0
- package/dist/subagent-timeline.js +156 -0
- package/dist/subagent-timeline.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 +7 -16
- package/dist/timebox/timebox-discover.js.map +1 -1
- package/dist/tui/gauge.d.ts +4 -2
- package/dist/tui/gauge.d.ts.map +1 -1
- package/dist/tui/gauge.js +8 -4
- package/dist/tui/gauge.js.map +1 -1
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +28 -0
- package/dist/tui/renderer.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/usage-api.d.ts +43 -0
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +114 -9
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts +19 -0
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +35 -0
- package/dist/usage-event.js.map +1 -1
- package/dist/utils/project-name.d.ts.map +1 -1
- package/dist/utils/project-name.js +1 -0
- package/dist/utils/project-name.js.map +1 -1
- package/dist/version-check.d.ts.map +1 -1
- package/dist/version-check.js +3 -2
- package/dist/version-check.js.map +1 -1
- package/dist/voice-assistant.d.ts.map +1 -1
- package/dist/voice-assistant.js +2 -1
- package/dist/voice-assistant.js.map +1 -1
- package/dist/voice.d.ts.map +1 -1
- package/dist/voice.js +1 -0
- package/dist/voice.js.map +1 -1
- package/dist/weather.d.ts +72 -0
- package/dist/weather.d.ts.map +1 -0
- package/dist/weather.js +206 -0
- package/dist/weather.js.map +1 -0
- package/dist/windows-service.js +5 -5
- package/dist/windows-service.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +200 -0
- package/package.json +20 -14
- package/python/requirements-ble.txt +5 -0
- package/src/idotmatrix/brightness.py +40 -0
- package/src/idotmatrix/identity_generated.py +50 -0
- package/src/idotmatrix/scan.py +55 -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,55 @@
|
|
|
1
|
+
"""BLE scan for iDotMatrix-protocol panels (terminal-managed daemon + CLI).
|
|
2
|
+
|
|
3
|
+
Reports every peripheral it sees, flagging the ones that speak the iDotMatrix
|
|
4
|
+
protocol. Identification is brand-independent — advertised service UUID first,
|
|
5
|
+
known name families second — because the same hardware ships as 'IDM-...'
|
|
6
|
+
(iDotMatrix) and 'iPixel-...'. The rule lives in identity_generated.py, mirrored
|
|
7
|
+
from shared/src/idotmatrix-identity.ts; the Swift CoreBluetooth scanner in the
|
|
8
|
+
App Store daemon applies the identical predicate.
|
|
9
|
+
|
|
10
|
+
Extra name prefixes may be passed as argv (the daemon forwards
|
|
11
|
+
`idotmatrixNamePrefixes` from settings.json) so a further rebrand is a config
|
|
12
|
+
change rather than a release.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from bleak import BleakScanner
|
|
19
|
+
|
|
20
|
+
from identity_generated import matches_advertisement
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def scan(extra_prefixes):
|
|
24
|
+
# Scan for 5 seconds. return_adv gives the advertisement payload, which is
|
|
25
|
+
# where the service UUIDs live — peripheral.name alone cannot identify a
|
|
26
|
+
# panel that advertises under an unknown brand name.
|
|
27
|
+
discovered = await BleakScanner.discover(timeout=5.0, return_adv=True)
|
|
28
|
+
|
|
29
|
+
results = []
|
|
30
|
+
for device, adv in discovered.values():
|
|
31
|
+
name = adv.local_name or device.name or "Unknown"
|
|
32
|
+
service_uuids = list(adv.service_uuids or ())
|
|
33
|
+
results.append({
|
|
34
|
+
"name": name,
|
|
35
|
+
"address": device.address,
|
|
36
|
+
"is_idotmatrix": matches_advertisement(name, service_uuids, extra_prefixes),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
# Sort so that iDotMatrix devices are on top
|
|
40
|
+
results.sort(key=lambda x: x["is_idotmatrix"], reverse=True)
|
|
41
|
+
|
|
42
|
+
# Print as JSON so the calling Node.js CLI can easily parse it
|
|
43
|
+
print(json.dumps(results))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main():
|
|
47
|
+
try:
|
|
48
|
+
asyncio.run(scan(sys.argv[1:]))
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print(json.dumps({"error": str(e)}))
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -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()
|