@agentdeck/bridge 0.2.2 → 1.0.0
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/assets/fonts/IBMPlexSans-Bold.ttf +0 -0
- package/assets/fonts/IBMPlexSans-Regular.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/assets/fonts/LICENSES.md +22 -0
- package/dist/adapters/claude-code.d.ts +6 -0
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +8 -0
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/openclaw.d.ts +24 -0
- package/dist/adapters/openclaw.d.ts.map +1 -1
- package/dist/adapters/openclaw.js +291 -35
- package/dist/adapters/openclaw.js.map +1 -1
- package/dist/adapters/opencode-adapter.d.ts +59 -0
- package/dist/adapters/opencode-adapter.d.ts.map +1 -1
- package/dist/adapters/opencode-adapter.js +297 -35
- package/dist/adapters/opencode-adapter.js.map +1 -1
- package/dist/adb-reverse.d.ts +1 -1
- package/dist/adb-reverse.js +1 -1
- package/dist/antigravity-local.d.ts +1 -0
- package/dist/antigravity-local.d.ts.map +1 -1
- package/dist/antigravity-local.js +33 -0
- package/dist/antigravity-local.js.map +1 -1
- package/dist/apme/adapters/claude-hook.d.ts.map +1 -1
- package/dist/apme/adapters/claude-hook.js +5 -0
- package/dist/apme/adapters/claude-hook.js.map +1 -1
- package/dist/apme/adapters/codex-hook.d.ts +17 -0
- package/dist/apme/adapters/codex-hook.d.ts.map +1 -0
- package/dist/apme/adapters/codex-hook.js +95 -0
- package/dist/apme/adapters/codex-hook.js.map +1 -0
- package/dist/apme/adapters/codex-turn-manager.d.ts +86 -0
- package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -0
- package/dist/apme/adapters/codex-turn-manager.js +496 -0
- package/dist/apme/adapters/codex-turn-manager.js.map +1 -0
- package/dist/apme/adapters/openclaw-hook.d.ts +99 -0
- package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -0
- package/dist/apme/adapters/openclaw-hook.js +234 -0
- package/dist/apme/adapters/openclaw-hook.js.map +1 -0
- package/dist/apme/adapters/opencode-hook.d.ts +56 -0
- package/dist/apme/adapters/opencode-hook.d.ts.map +1 -0
- package/dist/apme/adapters/opencode-hook.js +182 -0
- package/dist/apme/adapters/opencode-hook.js.map +1 -0
- package/dist/apme/adapters/timeline.d.ts.map +1 -1
- package/dist/apme/adapters/timeline.js +6 -1
- package/dist/apme/adapters/timeline.js.map +1 -1
- package/dist/apme/antigravity-transcript.d.ts +18 -0
- package/dist/apme/antigravity-transcript.d.ts.map +1 -0
- package/dist/apme/antigravity-transcript.js +94 -0
- package/dist/apme/antigravity-transcript.js.map +1 -0
- package/dist/apme/classify-turn.d.ts +13 -0
- package/dist/apme/classify-turn.d.ts.map +1 -0
- package/dist/apme/classify-turn.js +49 -0
- package/dist/apme/classify-turn.js.map +1 -0
- package/dist/apme/claude-transcript-reader.d.ts +10 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +35 -0
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/apme/collector.d.ts +133 -6
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +471 -23
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +117 -11
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +52 -6
- package/dist/apme/http.js.map +1 -1
- package/dist/apme/index.d.ts +26 -5
- package/dist/apme/index.d.ts.map +1 -1
- package/dist/apme/index.js +146 -9
- package/dist/apme/index.js.map +1 -1
- package/dist/apme/judge-detect.d.ts +26 -0
- package/dist/apme/judge-detect.d.ts.map +1 -0
- package/dist/apme/judge-detect.js +69 -0
- package/dist/apme/judge-detect.js.map +1 -0
- package/dist/apme/pareto.d.ts +39 -0
- package/dist/apme/pareto.d.ts.map +1 -0
- package/dist/apme/pareto.js +59 -0
- package/dist/apme/pareto.js.map +1 -0
- package/dist/apme/recommend.d.ts +3 -0
- package/dist/apme/recommend.d.ts.map +1 -1
- package/dist/apme/recommend.js +43 -1
- package/dist/apme/recommend.js.map +1 -1
- package/dist/apme/runner.d.ts +99 -4
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +743 -72
- package/dist/apme/runner.js.map +1 -1
- package/dist/apme/sample-to-timeline.d.ts +26 -0
- package/dist/apme/sample-to-timeline.d.ts.map +1 -0
- package/dist/apme/sample-to-timeline.js +95 -0
- package/dist/apme/sample-to-timeline.js.map +1 -0
- package/dist/apme/scorers/index.d.ts +47 -0
- package/dist/apme/scorers/index.d.ts.map +1 -0
- package/dist/apme/scorers/index.js +96 -0
- package/dist/apme/scorers/index.js.map +1 -0
- package/dist/apme/settings.d.ts +17 -11
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +30 -9
- package/dist/apme/settings.js.map +1 -1
- package/dist/apme/store.d.ts +33 -1
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +334 -25
- package/dist/apme/store.js.map +1 -1
- package/dist/awaiting-overlay.d.ts +112 -0
- package/dist/awaiting-overlay.d.ts.map +1 -0
- package/dist/awaiting-overlay.js +179 -0
- package/dist/awaiting-overlay.js.map +1 -0
- package/dist/ble-sync-spawn.d.ts +63 -0
- package/dist/ble-sync-spawn.d.ts.map +1 -0
- package/dist/ble-sync-spawn.js +196 -0
- package/dist/ble-sync-spawn.js.map +1 -0
- package/dist/bridge-core.d.ts +34 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +207 -24
- package/dist/bridge-core.js.map +1 -1
- package/dist/broadcast.d.ts +3 -0
- package/dist/broadcast.d.ts.map +1 -0
- package/dist/broadcast.js +122 -0
- package/dist/broadcast.js.map +1 -0
- package/dist/check-deps.d.ts.map +1 -1
- package/dist/check-deps.js +25 -9
- package/dist/check-deps.js.map +1 -1
- package/dist/claude-permission-rules.d.ts +55 -0
- package/dist/claude-permission-rules.d.ts.map +1 -0
- package/dist/claude-permission-rules.js +197 -0
- package/dist/claude-permission-rules.js.map +1 -0
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1030 -93
- package/dist/cli.js.map +1 -1
- package/dist/codex-auth.d.ts +21 -0
- package/dist/codex-auth.d.ts.map +1 -1
- package/dist/codex-auth.js +52 -1
- package/dist/codex-auth.js.map +1 -1
- package/dist/codex-output-parser.d.ts +8 -0
- package/dist/codex-output-parser.d.ts.map +1 -1
- package/dist/codex-output-parser.js +110 -25
- package/dist/codex-output-parser.js.map +1 -1
- package/dist/codex-rate-limits.d.ts +16 -0
- package/dist/codex-rate-limits.d.ts.map +1 -0
- package/dist/codex-rate-limits.js +197 -0
- package/dist/codex-rate-limits.js.map +1 -0
- package/dist/codex-rollout-response.d.ts +31 -0
- package/dist/codex-rollout-response.d.ts.map +1 -0
- package/dist/codex-rollout-response.js +135 -0
- package/dist/codex-rollout-response.js.map +1 -0
- package/dist/d200h/image-renderer.d.ts +4 -16
- package/dist/d200h/image-renderer.d.ts.map +1 -1
- package/dist/d200h/image-renderer.js +46 -212
- package/dist/d200h/image-renderer.js.map +1 -1
- package/dist/daemon-server.d.ts +85 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +1975 -159
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon.js +15 -6
- package/dist/daemon.js.map +1 -1
- package/dist/display-dim.d.ts +9 -0
- package/dist/display-dim.d.ts.map +1 -0
- package/dist/display-dim.js +53 -0
- package/dist/display-dim.js.map +1 -0
- package/dist/display-monitor.d.ts +15 -2
- package/dist/display-monitor.d.ts.map +1 -1
- package/dist/display-monitor.js +104 -26
- package/dist/display-monitor.js.map +1 -1
- package/dist/esp32-serial.d.ts +190 -1
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +1090 -156
- package/dist/esp32-serial.js.map +1 -1
- package/dist/fallback-task-timeline.d.ts +42 -0
- package/dist/fallback-task-timeline.d.ts.map +1 -0
- package/dist/fallback-task-timeline.js +119 -0
- package/dist/fallback-task-timeline.js.map +1 -0
- package/dist/foundation-models-helper.d.ts +11 -0
- package/dist/foundation-models-helper.d.ts.map +1 -0
- package/dist/foundation-models-helper.js +241 -0
- package/dist/foundation-models-helper.js.map +1 -0
- package/dist/hook-migration.js +1 -1
- package/dist/hook-migration.js.map +1 -1
- package/dist/hook-server.d.ts +2 -3
- package/dist/hook-server.d.ts.map +1 -1
- package/dist/hook-server.js +23 -10
- package/dist/hook-server.js.map +1 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts +33 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.js +164 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -0
- package/dist/idotmatrix/idotmatrix-discover.d.ts +18 -0
- package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-discover.js +85 -0
- package/dist/idotmatrix/idotmatrix-discover.js.map +1 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts +20 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-settings.js +57 -0
- package/dist/idotmatrix/idotmatrix-settings.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +217 -179
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +15 -16
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +18 -96
- package/dist/log-stream.js.map +1 -1
- package/dist/logger.d.ts +3 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +18 -5
- package/dist/logger.js.map +1 -1
- package/dist/mdns.d.ts +17 -0
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +52 -18
- package/dist/mdns.js.map +1 -1
- package/dist/model-catalog.d.ts +1 -1
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +2 -4
- package/dist/model-catalog.js.map +1 -1
- package/dist/modules/adb-module.d.ts +1 -1
- package/dist/modules/adb-module.d.ts.map +1 -1
- package/dist/modules/adb-module.js +4 -3
- package/dist/modules/adb-module.js.map +1 -1
- package/dist/modules/broadcast-module.d.ts +17 -0
- package/dist/modules/broadcast-module.d.ts.map +1 -0
- package/dist/modules/broadcast-module.js +26 -0
- package/dist/modules/broadcast-module.js.map +1 -0
- package/dist/modules/d200h-module.d.ts +22 -0
- package/dist/modules/d200h-module.d.ts.map +1 -1
- package/dist/modules/d200h-module.js +200 -65
- package/dist/modules/d200h-module.js.map +1 -1
- package/dist/modules/idotmatrix-module.d.ts +9 -0
- package/dist/modules/idotmatrix-module.d.ts.map +1 -0
- package/dist/modules/idotmatrix-module.js +46 -0
- package/dist/modules/idotmatrix-module.js.map +1 -0
- package/dist/modules/index.d.ts +3 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +9 -3
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/pixoo-module.d.ts.map +1 -1
- package/dist/modules/pixoo-module.js +14 -4
- package/dist/modules/pixoo-module.js.map +1 -1
- package/dist/modules/serial-module.d.ts +4 -0
- package/dist/modules/serial-module.d.ts.map +1 -1
- package/dist/modules/serial-module.js +14 -12
- package/dist/modules/serial-module.js.map +1 -1
- package/dist/modules/timebox-module.d.ts +9 -0
- package/dist/modules/timebox-module.d.ts.map +1 -0
- package/dist/modules/timebox-module.js +46 -0
- package/dist/modules/timebox-module.js.map +1 -0
- package/dist/modules/trmnl-module.d.ts +38 -0
- package/dist/modules/trmnl-module.d.ts.map +1 -0
- package/dist/modules/trmnl-module.js +111 -0
- package/dist/modules/trmnl-module.js.map +1 -0
- package/dist/modules/types.d.ts +4 -2
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/observed-steering.d.ts +93 -0
- package/dist/observed-steering.d.ts.map +1 -0
- package/dist/observed-steering.js +247 -0
- package/dist/observed-steering.js.map +1 -0
- package/dist/openclaw-session.d.ts +18 -0
- package/dist/openclaw-session.d.ts.map +1 -0
- package/dist/openclaw-session.js +34 -0
- package/dist/openclaw-session.js.map +1 -0
- package/dist/opencode-steering.d.ts +36 -0
- package/dist/opencode-steering.d.ts.map +1 -0
- package/dist/opencode-steering.js +84 -0
- package/dist/opencode-steering.js.map +1 -0
- package/dist/output-parser.d.ts +30 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +172 -18
- package/dist/output-parser.js.map +1 -1
- package/dist/passive-observer.d.ts +32 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +288 -49
- package/dist/passive-observer.js.map +1 -1
- package/dist/permission-resolver.d.ts +55 -0
- package/dist/permission-resolver.d.ts.map +1 -0
- package/dist/permission-resolver.js +112 -0
- package/dist/permission-resolver.js.map +1 -0
- package/dist/pixoo/micro-glyphs.d.ts +18 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
- package/dist/pixoo/micro-glyphs.js +143 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -0
- package/dist/pixoo/official-dot-glyphs.generated.d.ts +8 -0
- package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -0
- package/dist/pixoo/official-dot-glyphs.generated.js +247 -0
- package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -0
- package/dist/pixoo/pixoo-bridge.d.ts +16 -8
- package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
- package/dist/pixoo/pixoo-bridge.js +127 -35
- package/dist/pixoo/pixoo-bridge.js.map +1 -1
- package/dist/pixoo/pixoo-camera.d.ts +19 -3
- package/dist/pixoo/pixoo-camera.d.ts.map +1 -1
- package/dist/pixoo/pixoo-camera.js +62 -27
- package/dist/pixoo/pixoo-camera.js.map +1 -1
- package/dist/pixoo/pixoo-client.d.ts +13 -3
- package/dist/pixoo/pixoo-client.d.ts.map +1 -1
- package/dist/pixoo/pixoo-client.js +51 -22
- package/dist/pixoo/pixoo-client.js.map +1 -1
- package/dist/pixoo/pixoo-discover.d.ts +37 -0
- package/dist/pixoo/pixoo-discover.d.ts.map +1 -0
- package/dist/pixoo/pixoo-discover.js +105 -0
- package/dist/pixoo/pixoo-discover.js.map +1 -0
- package/dist/pixoo/pixoo-renderer.d.ts +27 -3
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +533 -135
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pixoo/pixoo-settings.d.ts +6 -0
- package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
- package/dist/pixoo/pixoo-settings.js +9 -0
- package/dist/pixoo/pixoo-settings.js.map +1 -1
- package/dist/pixoo/pixoo-sprites.d.ts +84 -11
- package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
- package/dist/pixoo/pixoo-sprites.js +620 -209
- package/dist/pixoo/pixoo-sprites.js.map +1 -1
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +8 -2
- package/dist/pty-manager.js.map +1 -1
- package/dist/review-runner.d.ts +113 -0
- package/dist/review-runner.d.ts.map +1 -0
- package/dist/review-runner.js +428 -0
- package/dist/review-runner.js.map +1 -0
- package/dist/session-activity.d.ts +14 -0
- package/dist/session-activity.d.ts.map +1 -0
- package/dist/session-activity.js +160 -0
- package/dist/session-activity.js.map +1 -0
- package/dist/session-aggregator.d.ts +25 -0
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js +48 -9
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-focus-relay.d.ts.map +1 -1
- package/dist/session-focus-relay.js +7 -1
- package/dist/session-focus-relay.js.map +1 -1
- package/dist/session-registry.d.ts +74 -3
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +140 -8
- package/dist/session-registry.js.map +1 -1
- package/dist/session-timeline-relay.d.ts.map +1 -1
- package/dist/session-timeline-relay.js +13 -4
- package/dist/session-timeline-relay.js.map +1 -1
- package/dist/session-transcript-timeline.d.ts +49 -0
- package/dist/session-transcript-timeline.d.ts.map +1 -0
- package/dist/session-transcript-timeline.js +276 -0
- package/dist/session-transcript-timeline.js.map +1 -0
- package/dist/state-machine.d.ts +6 -1
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +86 -28
- package/dist/state-machine.js.map +1 -1
- package/dist/timebox/timebox-daemon-sync.d.ts +19 -0
- package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -0
- package/dist/timebox/timebox-daemon-sync.js +135 -0
- package/dist/timebox/timebox-daemon-sync.js.map +1 -0
- package/dist/timebox/timebox-discover.d.ts +19 -0
- package/dist/timebox/timebox-discover.d.ts.map +1 -0
- package/dist/timebox/timebox-discover.js +87 -0
- package/dist/timebox/timebox-discover.js.map +1 -0
- package/dist/timebox/timebox-settings.d.ts +32 -0
- package/dist/timebox/timebox-settings.d.ts.map +1 -0
- package/dist/timebox/timebox-settings.js +80 -0
- package/dist/timebox/timebox-settings.js.map +1 -0
- package/dist/timeline-store.d.ts +112 -3
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +460 -14
- package/dist/timeline-store.js.map +1 -1
- package/dist/timeline-summarizer.d.ts.map +1 -1
- package/dist/timeline-summarizer.js +29 -5
- package/dist/timeline-summarizer.js.map +1 -1
- package/dist/trmnl/byos-server.d.ts +39 -0
- package/dist/trmnl/byos-server.d.ts.map +1 -0
- package/dist/trmnl/byos-server.js +301 -0
- package/dist/trmnl/byos-server.js.map +1 -0
- package/dist/trmnl/frame-cache.d.ts +37 -0
- package/dist/trmnl/frame-cache.d.ts.map +1 -0
- package/dist/trmnl/frame-cache.js +157 -0
- package/dist/trmnl/frame-cache.js.map +1 -0
- package/dist/trmnl/image-renderer.d.ts +27 -0
- package/dist/trmnl/image-renderer.d.ts.map +1 -0
- package/dist/trmnl/image-renderer.js +235 -0
- package/dist/trmnl/image-renderer.js.map +1 -0
- package/dist/trmnl/trmnl-settings.d.ts +96 -0
- package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
- package/dist/trmnl/trmnl-settings.js +177 -0
- package/dist/trmnl/trmnl-settings.js.map +1 -0
- package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
- package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
- package/dist/trmnl/trmnl-telemetry.js +56 -0
- package/dist/trmnl/trmnl-telemetry.js.map +1 -0
- package/dist/tui/ansi.d.ts +0 -4
- package/dist/tui/ansi.d.ts.map +1 -1
- package/dist/tui/ansi.js +14 -7
- package/dist/tui/ansi.js.map +1 -1
- package/dist/tui/dashboard.d.ts +1 -0
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +38 -11
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/tui/renderer.d.ts +36 -0
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +355 -121
- package/dist/tui/renderer.js.map +1 -1
- package/dist/tui/terrarium.d.ts.map +1 -1
- package/dist/tui/terrarium.js +44 -51
- package/dist/tui/terrarium.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +7 -1
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts +10 -3
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +98 -13
- package/dist/usage-event.js.map +1 -1
- package/dist/utility-proxy.d.ts.map +1 -1
- package/dist/utility-proxy.js +8 -0
- package/dist/utility-proxy.js.map +1 -1
- package/dist/utils/project-name.d.ts +3 -0
- package/dist/utils/project-name.d.ts.map +1 -1
- package/dist/utils/project-name.js +44 -2
- package/dist/utils/project-name.js.map +1 -1
- package/dist/version-check.d.ts.map +1 -1
- package/dist/version-check.js +1 -17
- package/dist/version-check.js.map +1 -1
- package/dist/windows-service.d.ts +26 -0
- package/dist/windows-service.d.ts.map +1 -0
- package/dist/windows-service.js +145 -0
- package/dist/windows-service.js.map +1 -0
- package/dist/ws-server.d.ts +17 -3
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +78 -8
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +112 -0
- package/package.json +10 -4
- package/scripts/build-fm-helper.mjs +26 -0
- package/LICENSE +0 -21
- package/dist/apme/tuner.d.ts +0 -64
- package/dist/apme/tuner.d.ts.map +0 -1
- package/dist/apme/tuner.js +0 -297
- package/dist/apme/tuner.js.map +0 -1
package/dist/esp32-serial.js
CHANGED
|
@@ -10,33 +10,130 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ESP32 side reads lines starting with '{' and passes to Protocol::parseMessage().
|
|
12
12
|
*/
|
|
13
|
-
import { exec } from 'child_process';
|
|
14
|
-
import {
|
|
13
|
+
import { exec, execFile } from 'child_process';
|
|
14
|
+
import { openSync, close, read, write, readFileSync, writeFileSync, mkdirSync, constants as fsConstants, } from 'fs';
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
import { dirname, join } from 'path';
|
|
15
17
|
import { SERIAL_FORWARDED_EVENTS } from '@agentdeck/shared/protocol';
|
|
16
18
|
import { formatResetTime } from '@agentdeck/shared';
|
|
17
|
-
import { debug } from './logger.js';
|
|
19
|
+
import { debug, logTagged } from './logger.js';
|
|
18
20
|
/** @internal Exported for testing only */
|
|
19
21
|
export const ESP32_PORT_PATTERNS = [
|
|
20
|
-
/\/dev\/cu\.usbserial-\
|
|
21
|
-
/\/dev\/cu\.wchusbserial\
|
|
22
|
-
/\/dev\/cu\.usbmodem\
|
|
23
|
-
/\/dev\/ttyUSB\
|
|
24
|
-
/\/dev\/ttyACM\
|
|
22
|
+
/\/dev\/cu\.usbserial-\w+/, // CP210x / CH340 (cu.usbserial-XXXX)
|
|
23
|
+
/\/dev\/cu\.wchusbserial\w+/, // CH340 (cu.wchusbserialXXXX — 86 Box)
|
|
24
|
+
/\/dev\/cu\.usbmodem\w+/, // Native USB JTAG (IPS 3.5", Round AMOLED)
|
|
25
|
+
/\/dev\/ttyUSB\w+/, // Linux CH340
|
|
26
|
+
/\/dev\/ttyACM\w+/, // Linux native USB
|
|
25
27
|
];
|
|
26
28
|
/** @internal Exported for testing only */
|
|
27
29
|
export const EXCLUDE_PATTERNS = [
|
|
28
30
|
/Bluetooth/i,
|
|
29
31
|
/WLAN/i,
|
|
30
32
|
];
|
|
33
|
+
/** No-traffic threshold. ESP32 firmware should answer device_info/heartbeat JSON. */
|
|
34
|
+
const STALE_THRESHOLD_MS = 60000; // 60 seconds
|
|
35
|
+
const INITIAL_READ_TIMEOUT_MS = 20000;
|
|
36
|
+
const DEVICE_INFO_RETRY_MS = 5000;
|
|
37
|
+
const DEVICE_INFO_READ_RETRY_MS = 5000;
|
|
38
|
+
const DEVICE_INFO_MAX_REQUESTS = 10;
|
|
39
|
+
// A serial port that opens fine but never speaks the AgentDeck protocol (e.g. a
|
|
40
|
+
// TRMNL e-ink panel, or any third-party USB-serial device) would otherwise be
|
|
41
|
+
// reopened every poll — and opening a port toggles DTR/RTS, which resets an
|
|
42
|
+
// ESP32. After this many failed identification cycles we denylist the port for a
|
|
43
|
+
// cooldown so we stop holding/resetting foreign hardware.
|
|
44
|
+
const FOREIGN_MAX_PROBE_FAILURES = 3;
|
|
45
|
+
const FOREIGN_DENYLIST_COOLDOWN_MS = 10 * 60_000; // 10 minutes, then re-probe once
|
|
46
|
+
const FOREIGN_CDC_GRACE_MS = 90_000; // a held-open CDC port gets this long to identify
|
|
47
|
+
// An *identified* CDC port (live or cache-restored board) that reads nothing for
|
|
48
|
+
// this long since (re)connect is half-open: the board reset/re-enumerated and its
|
|
49
|
+
// RX pipe is dead, but heartbeat writes keep succeeding so the write-death reaper
|
|
50
|
+
// never fires. Larger than FOREIGN_CDC_GRACE_MS so an unidentified foreign port is
|
|
51
|
+
// always caught by the foreign clause first.
|
|
52
|
+
const CDC_SILENT_READ_TIMEOUT_MS = 120_000;
|
|
53
|
+
// An *identified* UART port whose RX died mid-connection: it HAS read before,
|
|
54
|
+
// then went silent. The dual read+write stale clause can never catch this —
|
|
55
|
+
// heartbeat writes land in the kernel buffer every 5s, so writeAge stays ~0
|
|
56
|
+
// forever (same trap the CDC comment above documents, but for a port that used
|
|
57
|
+
// to read). With keepalive acks every 5s, this long a silence is ~36 missed
|
|
58
|
+
// acks: the board hung, rebooted into a bad state, or the CH340 RX path
|
|
59
|
+
// wedged. Recycle the FD so re-poll reopens it — the reopen's DTR/RTS toggle
|
|
60
|
+
// resets the board, which is the self-heal.
|
|
61
|
+
const UART_SILENT_READ_TIMEOUT_MS = 180_000;
|
|
62
|
+
const SERIAL_OPEN_PROBE_TIMEOUT_MS = 1500;
|
|
63
|
+
const SERIAL_WRITE_INTERVAL_MS = 120;
|
|
64
|
+
const SERIAL_MAX_QUEUE = 24;
|
|
65
|
+
// Firmware acks ONLY lines containing the quoted substring `"keepalive"`
|
|
66
|
+
// (esp32/src/net/serial_client.cpp sendHeartbeatAck) — those acks are the sole
|
|
67
|
+
// periodic board→host liveness signal, and every read-age check in this module
|
|
68
|
+
// (isResponsive, the half-open reapers) is calibrated to that 5s cadence. The
|
|
69
|
+
// former `serial_keepalive` type did NOT match the firmware's substring check,
|
|
70
|
+
// so boards never acked and the only reads were 60s device_info refreshes —
|
|
71
|
+
// one lost reply looked like a minute-long outage.
|
|
72
|
+
/** @internal Exported for testing only */
|
|
73
|
+
export const SERIAL_KEEPALIVE_JSON = JSON.stringify({ type: 'keepalive' });
|
|
74
|
+
const SERIAL_OPEN_PROBE_SCRIPT = `
|
|
75
|
+
const fs = require('fs');
|
|
76
|
+
const port = process.argv[1];
|
|
77
|
+
let fd = -1;
|
|
78
|
+
try {
|
|
79
|
+
fd = fs.openSync(port, fs.constants.O_RDWR | (fs.constants.O_NOCTTY || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
80
|
+
fs.closeSync(fd);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (fd >= 0) {
|
|
84
|
+
try { fs.closeSync(fd); } catch {}
|
|
85
|
+
}
|
|
86
|
+
console.error(err && err.message ? err.message : String(err));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
`;
|
|
31
90
|
let connections = [];
|
|
32
91
|
let pollTimer = null;
|
|
33
92
|
let heartbeatTimer = null;
|
|
93
|
+
let pollInProgress = false;
|
|
94
|
+
const openingPorts = new Set();
|
|
95
|
+
const lastKnownDeviceInfoByPort = new Map();
|
|
96
|
+
const lastErrorByPort = new Map();
|
|
97
|
+
// Foreign (non-AgentDeck) serial ports: failure count + denylist-until timestamp.
|
|
98
|
+
const foreignProbeFailures = new Map();
|
|
99
|
+
const foreignDenylistUntil = new Map();
|
|
100
|
+
let deviceInfoCacheLoaded = false;
|
|
34
101
|
let stateProvider = null;
|
|
35
102
|
let usageProvider = null;
|
|
103
|
+
let displayStateProvider = null;
|
|
104
|
+
let sessionsListProvider = null;
|
|
36
105
|
let initialStateProvider = null;
|
|
37
106
|
let messageHandler = null;
|
|
38
107
|
// Events to forward — shared constant from @agentdeck/shared
|
|
39
108
|
const FORWARDED_EVENTS = SERIAL_FORWARDED_EVENTS;
|
|
109
|
+
function deviceInfoCachePath() {
|
|
110
|
+
return join(process.env.AGENTDECK_DATA_DIR || join(homedir(), '.agentdeck'), 'esp32-device-cache.json');
|
|
111
|
+
}
|
|
112
|
+
function loadDeviceInfoCache() {
|
|
113
|
+
if (deviceInfoCacheLoaded)
|
|
114
|
+
return;
|
|
115
|
+
deviceInfoCacheLoaded = true;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(readFileSync(deviceInfoCachePath(), 'utf-8'));
|
|
118
|
+
for (const [port, info] of Object.entries(parsed)) {
|
|
119
|
+
if (info?.board)
|
|
120
|
+
lastKnownDeviceInfoByPort.set(port, info);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Missing cache is normal.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function persistDeviceInfoCache() {
|
|
128
|
+
try {
|
|
129
|
+
const path = deviceInfoCachePath();
|
|
130
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
131
|
+
writeFileSync(path, JSON.stringify(Object.fromEntries(lastKnownDeviceInfoByPort), null, 2), 'utf-8');
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
debug('ESP32', `Failed to persist device cache: ${String(err)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
40
137
|
// ESP32 without WiFi/NTP can't parse ISO dates — shared formatResetTime
|
|
41
138
|
// handles undefined and pre-formatted strings.
|
|
42
139
|
/**
|
|
@@ -45,28 +142,270 @@ const FORWARDED_EVENTS = SERIAL_FORWARDED_EVENTS;
|
|
|
45
142
|
* - Strip fields the ESP32 firmware doesn't parse (reduce size for small RX buffers)
|
|
46
143
|
*/
|
|
47
144
|
/** @internal Exported for testing only */
|
|
48
|
-
|
|
145
|
+
// Firmware (handleSessionsList) only stores the first N sessions; both sides
|
|
146
|
+
// must agree on the same cap. Keep in sync with esp32/src/net/protocol.cpp
|
|
147
|
+
// (sessions[10] / MOSAIC_MAX / min(...,10)).
|
|
148
|
+
export const SERIAL_SESSIONS_CAP = 10;
|
|
149
|
+
/**
|
|
150
|
+
* Pick which sessions survive the serial session cap.
|
|
151
|
+
*
|
|
152
|
+
* A naive `slice(0, cap)` lets a single agent type (e.g. several idle Claude Code
|
|
153
|
+
* sessions) fill every slot and starve other agents — a running Codex/OpenClaw
|
|
154
|
+
* session that lands past the cap never reaches the firmware's creature renderer.
|
|
155
|
+
*
|
|
156
|
+
* Instead we round-robin across agent types so each present type claims at least
|
|
157
|
+
* one slot before any type takes a second. Within a type, live + active
|
|
158
|
+
* (non-idle) sessions are preferred so a processing session is never dropped in
|
|
159
|
+
* favour of an idle sibling. Order within a type is otherwise preserved (stable).
|
|
160
|
+
*/
|
|
161
|
+
/** Total byte budget for a shipped timeline_history frame. The smallest board
|
|
162
|
+
* line buffer is 4096 (InkDeck is 8192) — a larger line is discarded whole
|
|
163
|
+
* on-device, so shipping it is pure waste at best and a WS-client killer at
|
|
164
|
+
* worst. Mirrors ESP32Serial.timelineHistoryByteBudget (Swift daemon). */
|
|
165
|
+
export const TIMELINE_HISTORY_BYTE_BUDGET = 3500;
|
|
166
|
+
/** Keep the NEWEST entries whose summed JSON size fits the byte budget,
|
|
167
|
+
* preserving chronological order for the ones that survive.
|
|
168
|
+
* @internal Exported for testing only */
|
|
169
|
+
export function budgetTimelineEntries(entries) {
|
|
170
|
+
const kept = [];
|
|
171
|
+
let total = 0;
|
|
172
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
173
|
+
const size = Buffer.byteLength(JSON.stringify(entries[i]), 'utf8') + 1;
|
|
174
|
+
if (total + size > TIMELINE_HISTORY_BYTE_BUDGET)
|
|
175
|
+
break;
|
|
176
|
+
total += size;
|
|
177
|
+
kept.push(entries[i]);
|
|
178
|
+
}
|
|
179
|
+
return kept.reverse();
|
|
180
|
+
}
|
|
181
|
+
export function roundRobinByAgentType(sessions, cap) {
|
|
182
|
+
if (sessions.length <= cap)
|
|
183
|
+
return sessions;
|
|
184
|
+
const groups = new Map();
|
|
185
|
+
const order = [];
|
|
186
|
+
for (const s of sessions) {
|
|
187
|
+
const key = typeof s?.agentType === 'string' ? s.agentType : '';
|
|
188
|
+
let bucket = groups.get(key);
|
|
189
|
+
if (!bucket) {
|
|
190
|
+
bucket = [];
|
|
191
|
+
groups.set(key, bucket);
|
|
192
|
+
order.push(key);
|
|
193
|
+
}
|
|
194
|
+
bucket.push(s);
|
|
195
|
+
}
|
|
196
|
+
// Within each type: alive first, then active (non-idle) first. Stable for ties.
|
|
197
|
+
const rank = (s) => {
|
|
198
|
+
const alive = s?.alive ? 1 : 0;
|
|
199
|
+
const state = typeof s?.state === 'string' ? s.state : '';
|
|
200
|
+
const active = state && state !== 'idle' ? 1 : 0;
|
|
201
|
+
return alive * 2 + active;
|
|
202
|
+
};
|
|
203
|
+
for (const key of order) {
|
|
204
|
+
groups.get(key).sort((a, b) => rank(b) - rank(a));
|
|
205
|
+
}
|
|
206
|
+
const result = [];
|
|
207
|
+
let progressed = true;
|
|
208
|
+
while (result.length < cap && progressed) {
|
|
209
|
+
progressed = false;
|
|
210
|
+
for (const key of order) {
|
|
211
|
+
const bucket = groups.get(key);
|
|
212
|
+
if (bucket.length === 0)
|
|
213
|
+
continue;
|
|
214
|
+
result.push(bucket.shift());
|
|
215
|
+
progressed = true;
|
|
216
|
+
if (result.length >= cap)
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
export function prepareForSerial(event, _conn) {
|
|
49
223
|
const e = event;
|
|
50
224
|
if (event.type === 'usage_update') {
|
|
51
|
-
//
|
|
52
|
-
|
|
225
|
+
// WHITELIST of the fields the firmware actually parses (protocol.cpp
|
|
226
|
+
// handleUsageUpdate). The old blocklist approach silently leaked every
|
|
227
|
+
// NEW usage field onto the wire — modelCatalog alone (3.2KB) pushed the
|
|
228
|
+
// line past the firmware's 4096-byte line buffer, so boards discarded
|
|
229
|
+
// EVERY usage_update and their gauges froze on stale values.
|
|
230
|
+
const cx = e.codexRateLimits
|
|
231
|
+
? {
|
|
232
|
+
primary: e.codexRateLimits.primary
|
|
233
|
+
? { usedPercent: e.codexRateLimits.primary.usedPercent, resetsAt: formatResetTime(e.codexRateLimits.primary.resetsAt), stale: e.codexRateLimits.primary.stale }
|
|
234
|
+
: undefined,
|
|
235
|
+
secondary: e.codexRateLimits.secondary
|
|
236
|
+
? { usedPercent: e.codexRateLimits.secondary.usedPercent, resetsAt: formatResetTime(e.codexRateLimits.secondary.resetsAt), stale: e.codexRateLimits.secondary.stale }
|
|
237
|
+
: undefined,
|
|
238
|
+
}
|
|
239
|
+
: undefined;
|
|
240
|
+
// Subscriptions carry an ISO `until`; a serial device has no reliable
|
|
241
|
+
// clock, so pre-format to a short "~M/D" the panel can render as-is.
|
|
242
|
+
const subs = Array.isArray(e.subscriptions)
|
|
243
|
+
? e.subscriptions.map((sub) => ({
|
|
244
|
+
name: limitString(sub.name, 27),
|
|
245
|
+
until: formatShortDate(sub.until),
|
|
246
|
+
})).filter((sub) => sub.name)
|
|
247
|
+
: undefined;
|
|
248
|
+
const ag = e.antigravityStatus
|
|
249
|
+
? { planName: limitString(e.antigravityStatus.planName, 23), availableCredits: e.antigravityStatus.availableCredits }
|
|
250
|
+
: undefined;
|
|
53
251
|
return {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
252
|
+
type: 'usage_update',
|
|
253
|
+
fiveHourPercent: e.fiveHourPercent,
|
|
254
|
+
sevenDayPercent: e.sevenDayPercent,
|
|
255
|
+
fiveHourResetsAt: formatResetTime(e.fiveHourResetsAt),
|
|
256
|
+
sevenDayResetsAt: formatResetTime(e.sevenDayResetsAt),
|
|
257
|
+
inputTokens: e.inputTokens,
|
|
258
|
+
outputTokens: e.outputTokens,
|
|
259
|
+
toolCalls: e.toolCalls,
|
|
260
|
+
sessionDurationSec: e.sessionDurationSec,
|
|
261
|
+
estimatedCostUsd: e.estimatedCostUsd,
|
|
262
|
+
usageStale: e.usageStale,
|
|
263
|
+
...(cx ? { codexRateLimits: cx } : {}),
|
|
264
|
+
...(subs ? { subscriptions: subs } : {}),
|
|
265
|
+
...(ag ? { antigravityStatus: ag } : {}),
|
|
57
266
|
};
|
|
58
267
|
}
|
|
59
268
|
if (event.type === 'state_update') {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
269
|
+
return {
|
|
270
|
+
type: 'state_update',
|
|
271
|
+
state: e.state,
|
|
272
|
+
permissionMode: e.permissionMode,
|
|
273
|
+
agentType: limitString(e.agentType, 15),
|
|
274
|
+
currentTool: limitString(e.currentTool, 39),
|
|
275
|
+
toolInput: limitString(e.toolInput, 79),
|
|
276
|
+
projectName: limitString(e.projectName, 39),
|
|
277
|
+
modelName: limitString(e.modelName, 31),
|
|
278
|
+
effortLevel: limitString(e.effortLevel, 7),
|
|
279
|
+
promptType: limitString(e.promptType, 19),
|
|
280
|
+
question: limitString(e.question, 199),
|
|
281
|
+
options: sanitizeOptions(e.options),
|
|
282
|
+
gatewayAvailable: Boolean(e.gatewayAvailable),
|
|
283
|
+
gatewayConnected: Boolean(e.gatewayConnected),
|
|
284
|
+
gatewayHasError: Boolean(e.gatewayHasError),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (event.type === 'timeline_event' || event.type === 'timeline_history') {
|
|
288
|
+
// Panels have no local timezone — attach host-local "HH:MM" so the
|
|
289
|
+
// InkDeck ticker (and future e-ink timelines) shows wall-clock time.
|
|
290
|
+
const stamp = (entry) => {
|
|
291
|
+
if (!entry || !Number.isFinite(entry.ts))
|
|
292
|
+
return entry;
|
|
293
|
+
const d = new Date(entry.ts);
|
|
294
|
+
return {
|
|
295
|
+
...entry,
|
|
296
|
+
// Bound to the firmware's TimelineEntry buffers (raw[120]/detail[200]/
|
|
297
|
+
// projectName[40]) so a history seed with long chat bodies can't
|
|
298
|
+
// balloon the line.
|
|
299
|
+
raw: limitString(entry.raw, 119) ?? '',
|
|
300
|
+
detail: limitString(entry.detail, 199),
|
|
301
|
+
...(typeof entry.projectName === 'string' ? { projectName: limitString(entry.projectName, 39) } : {}),
|
|
302
|
+
localHm: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
if (event.type === 'timeline_event')
|
|
306
|
+
return { ...e, entry: stamp(e.entry) };
|
|
307
|
+
// The firmware ring keeps at most 64 rows (TIMELINE_MAX_ENTRIES) — older
|
|
308
|
+
// entries would be shifted straight out again, so ship only the newest.
|
|
309
|
+
// Row cap alone is not enough: a busy afternoon of CJK entries made the
|
|
310
|
+
// 64-row frame ~37KB — far past the boards' 4KB line buffer, so serial
|
|
311
|
+
// firmware discarded it whole and heap-tight WiFi boards dropped the
|
|
312
|
+
// socket on it (chronic reconnect flap). Budget by BYTES, newest first.
|
|
313
|
+
return {
|
|
314
|
+
...e,
|
|
315
|
+
entries: Array.isArray(e.entries)
|
|
316
|
+
? budgetTimelineEntries(e.entries.slice(-64).map(stamp))
|
|
317
|
+
: e.entries,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (event.type === 'sessions_list') {
|
|
321
|
+
const raw = Array.isArray(e.sessions) ? e.sessions : [];
|
|
322
|
+
return {
|
|
323
|
+
type: 'sessions_list',
|
|
324
|
+
sessions: roundRobinByAgentType(raw, SERIAL_SESSIONS_CAP).map((s) => ({
|
|
325
|
+
id: limitString(s.id, 31),
|
|
326
|
+
projectName: limitString(s.projectName, 39),
|
|
327
|
+
modelName: limitString(s.modelName, 31),
|
|
328
|
+
agentType: limitString(s.agentType, 15),
|
|
329
|
+
state: limitString(s.state, 19),
|
|
330
|
+
port: Number.isFinite(s.port) ? s.port : 0,
|
|
331
|
+
alive: Boolean(s.alive),
|
|
332
|
+
// Per-session detail for the IPS10 D1 mosaic (cells show tool/elapsed,
|
|
333
|
+
// and awaiting cells render the real option buttons Claude reported).
|
|
334
|
+
currentTool: limitString(s.currentTool, 39),
|
|
335
|
+
promptType: limitString(s.promptType, 19),
|
|
336
|
+
question: limitString(s.question, 159),
|
|
337
|
+
elapsedSec: Number.isFinite(s.elapsedSec) ? Math.round(s.elapsedSec) : undefined,
|
|
338
|
+
// Shared activity one-liner — the glanceable "what is it doing" line
|
|
339
|
+
// (InkDeck session cards render it; other boards ignore it).
|
|
340
|
+
activity: limitString(s.activity, 79),
|
|
341
|
+
// Daemon-computed latest milestone (TIMELINE parity for the IPS10
|
|
342
|
+
// cards). Omitted when absent to spare the 4KB serial line budget.
|
|
343
|
+
...(typeof s.lastEventText === 'string' && s.lastEventText
|
|
344
|
+
? {
|
|
345
|
+
lastEventText: limitString(s.lastEventText, 99),
|
|
346
|
+
...(typeof s.lastEventTask === 'string' && s.lastEventTask
|
|
347
|
+
? { lastEventTask: limitString(s.lastEventTask, 39) } : {}),
|
|
348
|
+
...(typeof s.lastEventHm === 'string' && s.lastEventHm
|
|
349
|
+
? { lastEventHm: limitString(s.lastEventHm, 5) } : {}),
|
|
350
|
+
}
|
|
351
|
+
: {}),
|
|
352
|
+
options: sanitizeOptions(s.options),
|
|
353
|
+
})),
|
|
354
|
+
};
|
|
63
355
|
}
|
|
64
356
|
return event;
|
|
65
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Truncate to a UTF-8 BYTE budget on a code-point boundary. Firmware buffers are
|
|
360
|
+
* byte-sized (`char raw[120]`, `question[160]`, …); the old UTF-16 `slice(0, max)`
|
|
361
|
+
* let a 119-"char" 한글 string weigh ~357 bytes, so the board's strncpy did the
|
|
362
|
+
* real cut — mid-sequence — and the panel rendered a broken trailing glyph.
|
|
363
|
+
* Mirrored by the Swift daemon (ESP32Serial.limitUtf8Bytes).
|
|
364
|
+
*/
|
|
365
|
+
function limitString(value, maxBytes) {
|
|
366
|
+
if (typeof value !== 'string')
|
|
367
|
+
return undefined;
|
|
368
|
+
if (Buffer.byteLength(value, 'utf-8') <= maxBytes)
|
|
369
|
+
return value;
|
|
370
|
+
let used = 0;
|
|
371
|
+
let end = 0;
|
|
372
|
+
for (const ch of value) { // iterates code points, not UTF-16 units
|
|
373
|
+
const n = Buffer.byteLength(ch, 'utf-8');
|
|
374
|
+
if (used + n > maxBytes)
|
|
375
|
+
break;
|
|
376
|
+
used += n;
|
|
377
|
+
end += ch.length;
|
|
378
|
+
}
|
|
379
|
+
return value.slice(0, end);
|
|
380
|
+
}
|
|
381
|
+
/** ISO date → short "~M/D" for clock-less serial panels ('' when absent/invalid). */
|
|
382
|
+
function formatShortDate(iso) {
|
|
383
|
+
if (typeof iso !== 'string' || !iso)
|
|
384
|
+
return '';
|
|
385
|
+
const d = new Date(iso);
|
|
386
|
+
if (isNaN(d.getTime()))
|
|
387
|
+
return '';
|
|
388
|
+
return `~${d.getMonth() + 1}/${d.getDate()}`;
|
|
389
|
+
}
|
|
390
|
+
function sanitizeOptions(options) {
|
|
391
|
+
if (!Array.isArray(options))
|
|
392
|
+
return undefined;
|
|
393
|
+
return options.slice(0, 8).map((o, index) => ({
|
|
394
|
+
label: limitString(o?.label, 79) ?? '',
|
|
395
|
+
shortcut: limitString(o?.shortcut, 39),
|
|
396
|
+
index: Number.isFinite(o?.index) ? o.index : index,
|
|
397
|
+
recommended: Boolean(o?.recommended),
|
|
398
|
+
selected: Boolean(o?.selected),
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
66
401
|
/** Run a shell command with timeout, escalating to SIGKILL if SIGTERM fails. */
|
|
67
402
|
function execWithKill(cmd, timeoutMs = 3000) {
|
|
68
403
|
return new Promise((resolve, reject) => {
|
|
404
|
+
let settled = false;
|
|
69
405
|
const child = exec(cmd, { encoding: 'utf-8', timeout: timeoutMs }, (err, stdout) => {
|
|
406
|
+
if (settled)
|
|
407
|
+
return;
|
|
408
|
+
settled = true;
|
|
70
409
|
if (err) {
|
|
71
410
|
reject(err);
|
|
72
411
|
}
|
|
@@ -74,17 +413,94 @@ function execWithKill(cmd, timeoutMs = 3000) {
|
|
|
74
413
|
resolve(stdout);
|
|
75
414
|
}
|
|
76
415
|
});
|
|
77
|
-
//
|
|
78
|
-
//
|
|
416
|
+
// Serial ioctl can enter uninterruptible kernel I/O on broken CDC ports.
|
|
417
|
+
// On timeout, unblock our promise immediately instead of waiting for exit.
|
|
79
418
|
const killTimer = setTimeout(() => {
|
|
419
|
+
if (settled)
|
|
420
|
+
return;
|
|
421
|
+
settled = true;
|
|
80
422
|
try {
|
|
81
423
|
child.kill('SIGKILL');
|
|
82
424
|
}
|
|
83
425
|
catch { /* already dead */ }
|
|
426
|
+
reject(new Error(`command timed out: ${cmd}`));
|
|
84
427
|
}, timeoutMs + 1000);
|
|
85
428
|
child.on('exit', () => clearTimeout(killTimer));
|
|
86
429
|
});
|
|
87
430
|
}
|
|
431
|
+
function execFileWithKill(file, args, timeoutMs) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
let settled = false;
|
|
434
|
+
let killTimer = null;
|
|
435
|
+
const child = execFile(file, args, { encoding: 'utf-8', maxBuffer: 64 * 1024 }, (err, stdout, stderr) => {
|
|
436
|
+
if (settled)
|
|
437
|
+
return;
|
|
438
|
+
settled = true;
|
|
439
|
+
if (killTimer)
|
|
440
|
+
clearTimeout(killTimer);
|
|
441
|
+
if (err) {
|
|
442
|
+
const message = String(stderr || '').trim() || err.message;
|
|
443
|
+
reject(new Error(message));
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
killTimer = setTimeout(() => {
|
|
450
|
+
if (settled)
|
|
451
|
+
return;
|
|
452
|
+
settled = true;
|
|
453
|
+
try {
|
|
454
|
+
child.kill('SIGKILL');
|
|
455
|
+
}
|
|
456
|
+
catch { /* already dead */ }
|
|
457
|
+
reject(new Error(`timed out after ${timeoutMs}ms`));
|
|
458
|
+
}, timeoutMs);
|
|
459
|
+
child.on('exit', () => {
|
|
460
|
+
if (killTimer)
|
|
461
|
+
clearTimeout(killTimer);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
async function probePortOpenable(port) {
|
|
466
|
+
try {
|
|
467
|
+
await execFileWithKill(process.execPath, ['-e', SERIAL_OPEN_PROBE_SCRIPT, port], SERIAL_OPEN_PROBE_TIMEOUT_MS);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
471
|
+
throw new Error(`open probe failed: ${message}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async function openFdWithProbe(port) {
|
|
475
|
+
// Isolate potentially wedged macOS CDC/serial open calls in a short-lived
|
|
476
|
+
// process. Promise.race around fs.open() in this daemon can leave late file
|
|
477
|
+
// descriptors owned by the daemon after timeout, permanently blocking polls.
|
|
478
|
+
await probePortOpenable(port);
|
|
479
|
+
return openSync(port, fsConstants.O_RDWR | fsConstants.O_NOCTTY | fsConstants.O_NONBLOCK);
|
|
480
|
+
}
|
|
481
|
+
function readFromFd(fd, buf) {
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
read(fd, buf, 0, buf.length, null, (err, bytesRead) => {
|
|
484
|
+
if (err)
|
|
485
|
+
reject(err);
|
|
486
|
+
else
|
|
487
|
+
resolve({ bytesRead });
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
function writeToFd(fd, data) {
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
write(fd, data, (err) => {
|
|
494
|
+
if (err)
|
|
495
|
+
reject(err);
|
|
496
|
+
else
|
|
497
|
+
resolve();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
function closeFd(fd) {
|
|
502
|
+
close(fd, () => { });
|
|
503
|
+
}
|
|
88
504
|
async function detectESP32Ports() {
|
|
89
505
|
try {
|
|
90
506
|
const platform = process.platform;
|
|
@@ -104,28 +520,135 @@ async function detectESP32Ports() {
|
|
|
104
520
|
if (EXCLUDE_PATTERNS.some(p => p.test(port)))
|
|
105
521
|
return false;
|
|
106
522
|
return ESP32_PORT_PATTERNS.some(p => p.test(port));
|
|
107
|
-
});
|
|
523
|
+
}).sort(compareSerialPorts);
|
|
108
524
|
}
|
|
109
|
-
catch {
|
|
525
|
+
catch (err) {
|
|
526
|
+
lastErrorByPort.set('detect', `port detection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
527
|
return [];
|
|
111
528
|
}
|
|
112
529
|
}
|
|
530
|
+
function compareSerialPorts(a, b) {
|
|
531
|
+
const aNative = /usbmodem|ttyACM/.test(a);
|
|
532
|
+
const bNative = /usbmodem|ttyACM/.test(b);
|
|
533
|
+
if (aNative !== bNative)
|
|
534
|
+
return aNative ? 1 : -1;
|
|
535
|
+
return a.localeCompare(b);
|
|
536
|
+
}
|
|
537
|
+
function serialError(port, message) {
|
|
538
|
+
lastErrorByPort.set(port, message);
|
|
539
|
+
debug('ESP32', message);
|
|
540
|
+
}
|
|
541
|
+
function isResponsive(conn, now = Date.now()) {
|
|
542
|
+
if (!conn.connected)
|
|
543
|
+
return false;
|
|
544
|
+
const hasRead = conn.lastReadAt > 0;
|
|
545
|
+
if (hasRead && (now - conn.lastReadAt) <= STALE_THRESHOLD_MS) {
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
// Fallback for native USB CDC ports: if we have written to it successfully recently,
|
|
549
|
+
// and it is open, consider it responsive. This prevents native CDC displays (which may
|
|
550
|
+
// not send read data due to DTR/RTS line states in raw fs mode) from being marked disconnected.
|
|
551
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
552
|
+
if (isCDC && conn.lastWriteAt > 0 && (now - conn.lastWriteAt) <= STALE_THRESHOLD_MS) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
// If no read has occurred yet, allow an initial grace period
|
|
556
|
+
if (conn.lastReadAt <= 0) {
|
|
557
|
+
return (now - conn.connectedAt) <= INITIAL_READ_TIMEOUT_MS;
|
|
558
|
+
}
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
/** @internal Exported for testing only */
|
|
562
|
+
export function isRetryableSerialIoError(err) {
|
|
563
|
+
const code = err?.code;
|
|
564
|
+
return code === 'EAGAIN' || code === 'EWOULDBLOCK';
|
|
565
|
+
}
|
|
566
|
+
function hasLiveDeviceInfo(conn) {
|
|
567
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
568
|
+
if (isCDC) {
|
|
569
|
+
// CDC ports may have no read traffic but can still run dashboard/sessions if cached board type exists
|
|
570
|
+
return Boolean(conn.deviceInfo?.board);
|
|
571
|
+
}
|
|
572
|
+
return Boolean(conn.deviceInfo?.board) && conn.lastReadAt > 0;
|
|
573
|
+
}
|
|
574
|
+
// ESP32 crash output is plain text on the same UART/CDC stream as the JSON
|
|
575
|
+
// protocol. These markers open a capture window so the whole dump (register
|
|
576
|
+
// dump lines don't individually match) is logged before the board reboots.
|
|
577
|
+
const PANIC_MARKER_RE = /Guru Meditation|Backtrace:|register dump|abort\(\) was called|assert failed|Stack smashing|Stack canary|Debug exception reason|ELF file SHA256|Rebooting\.\.\./i;
|
|
578
|
+
const PANIC_CAPTURE_WINDOW_MS = 10_000;
|
|
579
|
+
const PANIC_CAPTURE_MAX_LINES = 200;
|
|
580
|
+
/** @internal Exported for testing only */
|
|
581
|
+
export function capturePanicLine(conn, line) {
|
|
582
|
+
const now = Date.now();
|
|
583
|
+
const inWindow = conn.panicLogUntil != null && now < conn.panicLogUntil;
|
|
584
|
+
const isMarker = PANIC_MARKER_RE.test(line);
|
|
585
|
+
if (!inWindow && !isMarker)
|
|
586
|
+
return false;
|
|
587
|
+
if (isMarker) {
|
|
588
|
+
// (Re)open the window on every marker — "Rebooting..." after the dump
|
|
589
|
+
// keeps the bootloader reset-cause lines in scope too.
|
|
590
|
+
conn.panicLogUntil = now + PANIC_CAPTURE_WINDOW_MS;
|
|
591
|
+
if (!inWindow)
|
|
592
|
+
conn.panicLogLines = 0;
|
|
593
|
+
}
|
|
594
|
+
const count = conn.panicLogLines ?? 0;
|
|
595
|
+
if (count >= PANIC_CAPTURE_MAX_LINES)
|
|
596
|
+
return false;
|
|
597
|
+
conn.panicLogLines = count + 1;
|
|
598
|
+
logTagged('esp32-panic', `${conn.port}: ${line}`);
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
113
601
|
/** @internal Exported for testing only */
|
|
114
602
|
export function handleSerialLine(conn, line) {
|
|
115
|
-
if (!line.startsWith('{'))
|
|
116
|
-
|
|
603
|
+
if (!line.startsWith('{')) {
|
|
604
|
+
// Not protocol JSON — usually boot/debug chatter, but crash dumps arrive
|
|
605
|
+
// here too. Capture those instead of dropping them.
|
|
606
|
+
capturePanicLine(conn, line);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
117
609
|
try {
|
|
118
610
|
const msg = JSON.parse(line);
|
|
119
611
|
if (msg.type) {
|
|
612
|
+
conn.lastReadAt = Date.now(); // Update last read timestamp
|
|
120
613
|
debug('ESP32', `← ${conn.port}: ${msg.type}`);
|
|
121
|
-
conn.deviceInfo = conn.deviceInfo || {};
|
|
122
614
|
if (msg.type === 'device_info') {
|
|
123
615
|
conn.deviceInfo = {
|
|
124
616
|
board: msg.board,
|
|
125
617
|
version: msg.version,
|
|
618
|
+
buildHash: msg.buildHash,
|
|
619
|
+
buildEpoch: msg.buildEpoch,
|
|
126
620
|
wifiConfigured: msg.wifiConfigured,
|
|
127
621
|
wifiConnected: msg.wifiConnected,
|
|
622
|
+
wifiRadioParked: msg.wifiRadioParked,
|
|
623
|
+
ip: msg.ip,
|
|
624
|
+
uptimeSec: msg.uptimeSec,
|
|
625
|
+
resetReason: msg.resetReason,
|
|
626
|
+
resetReasonCode: msg.resetReasonCode,
|
|
627
|
+
otaSupported: msg.otaSupported,
|
|
628
|
+
otaSlotCount: msg.otaSlotCount,
|
|
629
|
+
otaSlotSize: msg.otaSlotSize,
|
|
630
|
+
otaFreeSketchSpace: msg.otaFreeSketchSpace,
|
|
631
|
+
otaReason: msg.otaReason,
|
|
632
|
+
// Board-side reality counters (debug aid — surfaced on /devices so
|
|
633
|
+
// "device shows nothing" can be diagnosed without stealing the port)
|
|
634
|
+
timelineCount: msg.timelineCount,
|
|
635
|
+
sessionCount: msg.sessionCount,
|
|
636
|
+
usageFiveH: msg.usageFiveH,
|
|
637
|
+
processingCount: msg.processingCount,
|
|
128
638
|
};
|
|
639
|
+
conn.deviceInfoFresh = true;
|
|
640
|
+
if (conn.deviceInfo.board) {
|
|
641
|
+
lastKnownDeviceInfoByPort.set(conn.port, conn.deviceInfo);
|
|
642
|
+
persistDeviceInfoCache();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
else if (!conn.deviceInfoFresh &&
|
|
646
|
+
conn.deviceInfoRequestsSent < DEVICE_INFO_MAX_REQUESTS &&
|
|
647
|
+
Date.now() - conn.lastDeviceInfoRequestAt >= DEVICE_INFO_READ_RETRY_MS) {
|
|
648
|
+
// Retry until a device_info arrives on THIS connection — the cache
|
|
649
|
+
// seed keeps /devices populated meanwhile but must not silence the
|
|
650
|
+
// re-identify (a lost reply or reflashed board would stay stale).
|
|
651
|
+
sendDeviceInfoRequest(conn);
|
|
129
652
|
}
|
|
130
653
|
if (messageHandler) {
|
|
131
654
|
messageHandler(conn.port, msg);
|
|
@@ -136,35 +659,16 @@ export function handleSerialLine(conn, line) {
|
|
|
136
659
|
// Not valid JSON — ignore (ESP32 debug output)
|
|
137
660
|
}
|
|
138
661
|
}
|
|
139
|
-
async function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// On macOS, opening a serial device can reset stty settings to defaults (9600).
|
|
143
|
-
// So stty must run AFTER the file descriptors are open.
|
|
144
|
-
const stream = createWriteStream(port, { flags: 'w' });
|
|
145
|
-
const conn = {
|
|
146
|
-
port, stream, reader: null, readBuf: '',
|
|
147
|
-
connected: true, deviceInfo: null, provisionSent: false,
|
|
148
|
-
};
|
|
149
|
-
stream.on('error', (err) => {
|
|
150
|
-
debug('ESP32', `Serial write error on ${port}: ${err.message}`);
|
|
151
|
-
conn.connected = false;
|
|
152
|
-
try {
|
|
153
|
-
conn.reader?.destroy();
|
|
154
|
-
}
|
|
155
|
-
catch { /* ignore */ }
|
|
156
|
-
});
|
|
157
|
-
stream.on('close', () => {
|
|
158
|
-
debug('ESP32', `Serial write closed: ${port}`);
|
|
159
|
-
conn.connected = false;
|
|
160
|
-
});
|
|
161
|
-
// Open read stream for incoming ESP32 messages
|
|
662
|
+
async function startReadLoop(conn) {
|
|
663
|
+
const buf = Buffer.alloc(1024);
|
|
664
|
+
while (conn.connected && conn.fd != null) {
|
|
162
665
|
try {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
666
|
+
const fd = conn.fd;
|
|
667
|
+
if (fd == null)
|
|
668
|
+
break;
|
|
669
|
+
const { bytesRead } = await readFromFd(fd, buf);
|
|
670
|
+
if (bytesRead > 0) {
|
|
671
|
+
const str = buf.toString('utf-8', 0, bytesRead);
|
|
168
672
|
conn.readBuf += str;
|
|
169
673
|
let newlineIdx;
|
|
170
674
|
while ((newlineIdx = conn.readBuf.indexOf('\n')) !== -1) {
|
|
@@ -174,55 +678,67 @@ async function openPort(port) {
|
|
|
174
678
|
handleSerialLine(conn, line);
|
|
175
679
|
}
|
|
176
680
|
}
|
|
177
|
-
// Prevent buffer bloat from non-newline data
|
|
178
681
|
if (conn.readBuf.length > 8192) {
|
|
179
682
|
conn.readBuf = '';
|
|
180
683
|
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
reader.destroy();
|
|
187
|
-
}
|
|
188
|
-
catch { /* ignore */ }
|
|
189
|
-
try {
|
|
190
|
-
conn.stream.end();
|
|
191
|
-
}
|
|
192
|
-
catch { /* ignore */ }
|
|
193
|
-
});
|
|
194
|
-
reader.on('close', () => {
|
|
195
|
-
conn.reader = null;
|
|
196
|
-
conn.connected = false;
|
|
197
|
-
});
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
// EOF
|
|
687
|
+
await new Promise(r => setTimeout(r, 20));
|
|
688
|
+
}
|
|
198
689
|
}
|
|
199
690
|
catch (err) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
// Configure baud rate AFTER file descriptors are open.
|
|
204
|
-
// On macOS, open() on serial devices can reset stty to defaults (9600).
|
|
205
|
-
// Skip stty for native USB CDC ports (cu.usbmodem / ttyACM) — they use
|
|
206
|
-
// USB bus speed, not UART baud rate. stty can interfere with open fds.
|
|
207
|
-
const isCDC = /usbmodem|ttyACM/.test(port);
|
|
208
|
-
if (!isCDC) {
|
|
209
|
-
const platform = process.platform;
|
|
210
|
-
if (platform === 'darwin') {
|
|
211
|
-
await execWithKill(`stty -f ${port} 115200 cs8 -cstopb -parenb -hupcl`);
|
|
691
|
+
if (err.code === 'EAGAIN' || err.code === 'EWOULDBLOCK') {
|
|
692
|
+
// No data right now, wait 20ms and try again
|
|
693
|
+
await new Promise(r => setTimeout(r, 20));
|
|
212
694
|
}
|
|
213
|
-
else
|
|
214
|
-
|
|
695
|
+
else {
|
|
696
|
+
debug('ESP32', `Serial read error on ${conn.port}: ${err.message}`);
|
|
697
|
+
closeConnection(conn);
|
|
698
|
+
break;
|
|
215
699
|
}
|
|
216
700
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async function openPort(port) {
|
|
704
|
+
let fd = null;
|
|
705
|
+
try {
|
|
706
|
+
// Use one read/write fd. Opening /dev/cu.* separately for read and write
|
|
707
|
+
// can make macOS serial look writable while ESP32 never receives host JSON.
|
|
708
|
+
fd = await openFdWithProbe(port);
|
|
709
|
+
const platform = process.platform;
|
|
710
|
+
const isCDC = /usbmodem|ttyACM/.test(port);
|
|
711
|
+
if (!isCDC && platform === 'darwin') {
|
|
712
|
+
await execWithKill(`stty -f ${port} 115200 cs8 -cstopb -parenb -hupcl raw -echo`);
|
|
713
|
+
}
|
|
714
|
+
else if (!isCDC && platform === 'linux') {
|
|
715
|
+
await execWithKill(`stty -F ${port} 115200 cs8 -cstopb -parenb -hupcl raw -echo`);
|
|
221
716
|
}
|
|
717
|
+
const stream = {
|
|
718
|
+
destroyed: false,
|
|
719
|
+
writable: true,
|
|
720
|
+
write: () => true,
|
|
721
|
+
destroy: () => { },
|
|
722
|
+
on: () => { },
|
|
723
|
+
};
|
|
724
|
+
const conn = {
|
|
725
|
+
port, fd, stream, reader: null, readBuf: '',
|
|
726
|
+
connected: true, deviceInfo: lastKnownDeviceInfoByPort.get(port) ?? null,
|
|
727
|
+
deviceInfoFresh: false, provisionSent: false,
|
|
728
|
+
connectedAt: Date.now(),
|
|
729
|
+
lastReadAt: 0,
|
|
730
|
+
lastWriteAt: Date.now(),
|
|
731
|
+
lastDeviceInfoRequestAt: 0,
|
|
732
|
+
deviceInfoRequestsSent: 0,
|
|
733
|
+
writeQueue: [],
|
|
734
|
+
writeTimer: null,
|
|
735
|
+
};
|
|
222
736
|
const portType = isCDC ? 'CDC' : 'UART';
|
|
223
737
|
debug('ESP32', `Opened serial port (r/w): ${port} [${portType}]`);
|
|
738
|
+
// Start async read loop
|
|
739
|
+
void startReadLoop(conn);
|
|
224
740
|
// Request device info on connect
|
|
225
|
-
|
|
741
|
+
sendDeviceInfoRequest(conn);
|
|
226
742
|
// Send full initial state (state + usage + sessions) so ESP32 doesn't
|
|
227
743
|
// have to wait for next state change or heartbeat cycle
|
|
228
744
|
if (initialStateProvider) {
|
|
@@ -233,34 +749,96 @@ async function openPort(port) {
|
|
|
233
749
|
// Skip usage_update without API data — would reset ESP32 to "no data"
|
|
234
750
|
if (event.type === 'usage_update' && event.fiveHourPercent == null)
|
|
235
751
|
continue;
|
|
236
|
-
|
|
752
|
+
if ((event.type === 'usage_update' || event.type === 'sessions_list') && !hasLiveDeviceInfo(conn))
|
|
753
|
+
continue;
|
|
754
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
237
755
|
}
|
|
238
756
|
}
|
|
239
757
|
else if (stateProvider) {
|
|
240
758
|
const event = stateProvider();
|
|
241
759
|
if (event)
|
|
242
|
-
sendToConnection(conn, JSON.stringify(event));
|
|
760
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
243
761
|
}
|
|
244
762
|
return conn;
|
|
245
763
|
}
|
|
246
764
|
catch (err) {
|
|
247
|
-
|
|
765
|
+
if (fd != null) {
|
|
766
|
+
closeFd(fd);
|
|
767
|
+
}
|
|
768
|
+
serialError(port, `Failed to open ${port}: ${err.message}`);
|
|
248
769
|
return null;
|
|
249
770
|
}
|
|
250
771
|
}
|
|
251
|
-
function
|
|
772
|
+
function closeConnection(conn) {
|
|
773
|
+
conn.connected = false;
|
|
774
|
+
if (conn.writeTimer) {
|
|
775
|
+
clearTimeout(conn.writeTimer);
|
|
776
|
+
conn.writeTimer = null;
|
|
777
|
+
}
|
|
778
|
+
conn.writeQueue = [];
|
|
779
|
+
if (conn.fd != null)
|
|
780
|
+
closeFd(conn.fd);
|
|
781
|
+
conn.fd = null;
|
|
782
|
+
}
|
|
783
|
+
function sendToConnection(conn, json, priority = false) {
|
|
252
784
|
if (!conn.connected)
|
|
253
785
|
return;
|
|
254
|
-
if (
|
|
255
|
-
conn.
|
|
256
|
-
return;
|
|
786
|
+
if (priority) {
|
|
787
|
+
conn.writeQueue.unshift(json);
|
|
257
788
|
}
|
|
258
|
-
|
|
259
|
-
conn.
|
|
789
|
+
else {
|
|
790
|
+
conn.writeQueue.push(json);
|
|
260
791
|
}
|
|
261
|
-
|
|
262
|
-
conn.
|
|
792
|
+
while (conn.writeQueue.length > SERIAL_MAX_QUEUE) {
|
|
793
|
+
const dropIdx = conn.writeQueue.findIndex((queued) => queued.startsWith('{"type":"state_update"') || queued.startsWith('{"type":"sessions_list"'));
|
|
794
|
+
conn.writeQueue.splice(dropIdx >= 0 ? dropIdx : 0, 1);
|
|
263
795
|
}
|
|
796
|
+
scheduleWriteFlush(conn, 0);
|
|
797
|
+
}
|
|
798
|
+
function scheduleWriteFlush(conn, delayMs) {
|
|
799
|
+
if (conn.writeTimer || !conn.connected)
|
|
800
|
+
return;
|
|
801
|
+
conn.writeTimer = setTimeout(() => flushNextWrite(conn), delayMs);
|
|
802
|
+
}
|
|
803
|
+
function flushNextWrite(conn) {
|
|
804
|
+
conn.writeTimer = null;
|
|
805
|
+
if (!conn.connected)
|
|
806
|
+
return;
|
|
807
|
+
const json = conn.writeQueue.shift();
|
|
808
|
+
if (!json)
|
|
809
|
+
return;
|
|
810
|
+
if (conn.fd == null) {
|
|
811
|
+
closeConnection(conn);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
writeToFd(conn.fd, json + '\n')
|
|
815
|
+
.then(() => {
|
|
816
|
+
conn.lastWriteAt = Date.now();
|
|
817
|
+
if (conn.writeQueue.length > 0) {
|
|
818
|
+
scheduleWriteFlush(conn, SERIAL_WRITE_INTERVAL_MS);
|
|
819
|
+
}
|
|
820
|
+
})
|
|
821
|
+
.catch((err) => {
|
|
822
|
+
if (isRetryableSerialIoError(err)) {
|
|
823
|
+
conn.writeQueue.unshift(json);
|
|
824
|
+
scheduleWriteFlush(conn, SERIAL_WRITE_INTERVAL_MS);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
debug('ESP32', `Serial write error on ${conn.port}: ${err.message}`);
|
|
828
|
+
closeConnection(conn);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
function sendDeviceInfoRequest(conn) {
|
|
832
|
+
conn.lastDeviceInfoRequestAt = Date.now();
|
|
833
|
+
conn.deviceInfoRequestsSent++;
|
|
834
|
+
sendToConnection(conn, JSON.stringify({ type: 'device_info_request' }), true);
|
|
835
|
+
}
|
|
836
|
+
function sendSerialKeepalive(conn) {
|
|
837
|
+
// ESP32 marks USB as connected only after successfully parsing recent JSON.
|
|
838
|
+
// Keep this independent from larger state/session payloads so display
|
|
839
|
+
// reconnect overlays do not flap when a board is slow to parse UI updates.
|
|
840
|
+
conn.writeQueue = conn.writeQueue.filter((queued) => queued !== SERIAL_KEEPALIVE_JSON);
|
|
841
|
+
sendToConnection(conn, SERIAL_KEEPALIVE_JSON, true);
|
|
264
842
|
}
|
|
265
843
|
/**
|
|
266
844
|
* Register a callback that returns the current state_update event.
|
|
@@ -277,6 +855,25 @@ export function setESP32StateProvider(provider) {
|
|
|
277
855
|
export function setESP32UsageProvider(provider) {
|
|
278
856
|
usageProvider = provider;
|
|
279
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* Register a callback that returns the current display_state event.
|
|
860
|
+
* display_state is otherwise edge-triggered (on change + on connect); a board
|
|
861
|
+
* that misses the wake edge (half-open serial, daemon handoff) would stay
|
|
862
|
+
* dark until power-cycled. The heartbeat re-sync makes it self-heal.
|
|
863
|
+
*/
|
|
864
|
+
export function setESP32DisplayStateProvider(provider) {
|
|
865
|
+
displayStateProvider = provider;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Register a callback that returns the current sessions_list event.
|
|
869
|
+
* Like display_state it is otherwise edge-triggered (on change + on connect);
|
|
870
|
+
* a board that (re)connects during a quiet window — daemon handoff, half-open
|
|
871
|
+
* serial — would sit on an empty roster ("no active sessions") until the next
|
|
872
|
+
* unrelated session change. The heartbeat re-sync makes it self-heal.
|
|
873
|
+
*/
|
|
874
|
+
export function setESP32SessionsListProvider(provider) {
|
|
875
|
+
sessionsListProvider = provider;
|
|
876
|
+
}
|
|
280
877
|
/**
|
|
281
878
|
* Register a provider that returns all initial state events (state + usage + sessions).
|
|
282
879
|
* Called when a new ESP32 device connects to send full state immediately.
|
|
@@ -296,28 +893,249 @@ function sendHeartbeat() {
|
|
|
296
893
|
if (connections.length === 0)
|
|
297
894
|
return;
|
|
298
895
|
heartbeatCount++;
|
|
896
|
+
for (const conn of connections) {
|
|
897
|
+
if (conn.connected)
|
|
898
|
+
sendSerialKeepalive(conn);
|
|
899
|
+
// Refresh the identity snapshot about once a minute even after a fresh
|
|
900
|
+
// device_info landed — its debug counters (sessionCount/timelineCount)
|
|
901
|
+
// are point-in-time, and a connect-moment snapshot reading "0" on
|
|
902
|
+
// /devices masquerades as "board parses nothing".
|
|
903
|
+
if (conn.deviceInfoFresh && Date.now() - conn.lastDeviceInfoRequestAt > 60_000) {
|
|
904
|
+
sendDeviceInfoRequest(conn);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
299
907
|
// Send state_update (stripped for serial — smaller payload)
|
|
300
908
|
if (stateProvider) {
|
|
301
909
|
const event = stateProvider();
|
|
302
910
|
if (event) {
|
|
303
|
-
const json = JSON.stringify(prepareForSerial(event));
|
|
304
911
|
for (const conn of connections) {
|
|
305
|
-
sendToConnection(conn,
|
|
912
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// Re-sync display_state every cycle. It is edge-triggered elsewhere, and a
|
|
917
|
+
// board that misses a wake edge would otherwise stay blacked out until the
|
|
918
|
+
// next reconnect or a power cycle (payload is ~70 bytes; the firmware
|
|
919
|
+
// handler is idempotent).
|
|
920
|
+
if (displayStateProvider) {
|
|
921
|
+
const event = displayStateProvider();
|
|
922
|
+
if (event) {
|
|
923
|
+
for (const conn of connections) {
|
|
924
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Re-sync sessions_list every cycle for the same reason: it is edge-triggered
|
|
929
|
+
// elsewhere, so a board that reconnects during a quiet window shows "no active
|
|
930
|
+
// sessions" until the next session change. Only send once a device has
|
|
931
|
+
// identified (matches the sessions_list gating in the broadcast path).
|
|
932
|
+
if (sessionsListProvider) {
|
|
933
|
+
const event = sessionsListProvider();
|
|
934
|
+
if (event) {
|
|
935
|
+
for (const conn of connections) {
|
|
936
|
+
if (!hasLiveDeviceInfo(conn))
|
|
937
|
+
continue;
|
|
938
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
306
939
|
}
|
|
307
940
|
}
|
|
308
941
|
}
|
|
309
942
|
// Send usage_update (so ESP32 always has fresh usage/reset times)
|
|
310
|
-
// Only send
|
|
311
|
-
// otherwise the ESP32 would reset its cached values
|
|
943
|
+
// Only send once SOME usage signal is present — Claude 5h/7d, Codex limits,
|
|
944
|
+
// or Antigravity credits — otherwise the ESP32 would reset its cached values
|
|
945
|
+
// to the "no data" sentinel before any provider has populated them.
|
|
312
946
|
if (usageProvider) {
|
|
313
947
|
const event = usageProvider();
|
|
314
|
-
|
|
315
|
-
|
|
948
|
+
const u = event;
|
|
949
|
+
const hasUsage = u && (u.fiveHourPercent != null || u.codexRateLimits != null || u.antigravityStatus != null);
|
|
950
|
+
if (hasUsage && event) {
|
|
316
951
|
for (const conn of connections) {
|
|
317
|
-
|
|
952
|
+
if (!hasLiveDeviceInfo(conn))
|
|
953
|
+
continue;
|
|
954
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
318
955
|
}
|
|
319
956
|
}
|
|
320
957
|
}
|
|
958
|
+
// Retry device identification. Some boards answer heartbeats/provisioning
|
|
959
|
+
// but miss the first request after USB reset; without a retry they stay
|
|
960
|
+
// anonymous and surfaces render them as reconnecting.
|
|
961
|
+
//
|
|
962
|
+
// Gate on deviceInfoFresh, NOT conn.deviceInfo: a cache-seeded connection has
|
|
963
|
+
// a non-null (stale) deviceInfo but deviceInfoFresh=false. Keying off
|
|
964
|
+
// conn.deviceInfo froze such a connection at the cache seed forever — a board
|
|
965
|
+
// reflashed/OTA-updated on the same port (e.g. inkdeck round8→round9) would
|
|
966
|
+
// keep reporting its old buildHash/no-OTA because no path ever re-requested.
|
|
967
|
+
// deviceInfoFresh only flips true once a LIVE device_info lands on THIS
|
|
968
|
+
// connection, so a fresh board stops the retries (capped at MAX) as before.
|
|
969
|
+
const now = Date.now();
|
|
970
|
+
for (const conn of connections) {
|
|
971
|
+
if (!shouldRetryDeviceInfoIdentify(conn, now))
|
|
972
|
+
continue;
|
|
973
|
+
const seeded = conn.deviceInfo ? ' (cache-seeded — refreshing identity)' : '';
|
|
974
|
+
debug('ESP32', `${conn.port}: no live device_info yet${seeded} — retrying request`);
|
|
975
|
+
sendDeviceInfoRequest(conn);
|
|
976
|
+
}
|
|
977
|
+
// Check for stale connections and trigger recovery
|
|
978
|
+
checkStaleConnections();
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* A connection that opened cleanly but has never spoken the AgentDeck protocol:
|
|
982
|
+
* no device_info, no cached board for this port, and not a single valid JSON
|
|
983
|
+
* line read. Strong evidence it's a foreign device (e.g. a TRMNL panel) we
|
|
984
|
+
* should stop holding/resetting rather than an AgentDeck board that's just slow.
|
|
985
|
+
*/
|
|
986
|
+
/** @internal Exported for testing only */
|
|
987
|
+
export function isUnidentifiedForeign(conn) {
|
|
988
|
+
return !conn.deviceInfo?.board && !lastKnownDeviceInfoByPort.has(conn.port) && conn.lastReadAt === 0;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* A CDC port that IS an AgentDeck board (live device_info, or a cached board for
|
|
992
|
+
* this port) yet has produced no read since this (re)connection. A healthy board
|
|
993
|
+
* answers device_info/keepalive within seconds, so lastReadAt===0 well past a
|
|
994
|
+
* generous grace means a half-open USB-CDC pipe (board reset / re-enumerated)
|
|
995
|
+
* that heartbeat writes will never revive — the FD must be recycled so re-poll
|
|
996
|
+
* re-probes. Mutually exclusive with isUnidentifiedForeign (this REQUIRES an
|
|
997
|
+
* identified board; that one requires none).
|
|
998
|
+
*/
|
|
999
|
+
/** @internal Exported for testing only */
|
|
1000
|
+
export function isHalfOpenIdentifiedCdc(conn, now = Date.now()) {
|
|
1001
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
1002
|
+
if (!isCDC)
|
|
1003
|
+
return false;
|
|
1004
|
+
const isIdentified = Boolean(conn.deviceInfo?.board) || lastKnownDeviceInfoByPort.has(conn.port);
|
|
1005
|
+
return isIdentified && conn.lastReadAt === 0 && (now - conn.connectedAt) > CDC_SILENT_READ_TIMEOUT_MS;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* A UART port that IS an AgentDeck board and used to read, but has been
|
|
1009
|
+
* RX-silent past UART_SILENT_READ_TIMEOUT_MS. Half-open mirror of
|
|
1010
|
+
* isHalfOpenIdentifiedCdc for mid-life RX death: successful heartbeat writes
|
|
1011
|
+
* keep the write-death clause from ever firing, so without this check the
|
|
1012
|
+
* zombie FD is held forever and the board can never be DTR/RTS-recycled.
|
|
1013
|
+
* (2026-07-17 live case: ips_10 + ulanzi_tc001 + ttgo silent 4–20+ min with
|
|
1014
|
+
* transportOpen=true, lastWriteSecondsAgo=0.)
|
|
1015
|
+
*/
|
|
1016
|
+
/** @internal Exported for testing only */
|
|
1017
|
+
export function isSilentIdentifiedUart(conn, now = Date.now()) {
|
|
1018
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
1019
|
+
if (isCDC)
|
|
1020
|
+
return false;
|
|
1021
|
+
const isIdentified = Boolean(conn.deviceInfo?.board) || lastKnownDeviceInfoByPort.has(conn.port);
|
|
1022
|
+
return isIdentified && conn.lastReadAt > 0 && (now - conn.lastReadAt) > UART_SILENT_READ_TIMEOUT_MS;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Whether the periodic heartbeat should (re)send a device_info_request to
|
|
1026
|
+
* re-identify this connection. Keys off deviceInfoFresh (a LIVE reply on THIS
|
|
1027
|
+
* connection), NOT conn.deviceInfo — a cache-seeded connection carries a stale
|
|
1028
|
+
* deviceInfo but deviceInfoFresh=false and must keep re-requesting until the
|
|
1029
|
+
* board answers, so a reflashed/OTA-updated board on the same port refreshes
|
|
1030
|
+
* its buildHash/OTA fields instead of freezing at the cache seed. Capped by
|
|
1031
|
+
* DEVICE_INFO_MAX_REQUESTS and paced by DEVICE_INFO_RETRY_MS.
|
|
1032
|
+
*/
|
|
1033
|
+
/** @internal Exported for testing only */
|
|
1034
|
+
export function shouldRetryDeviceInfoIdentify(conn, now = Date.now()) {
|
|
1035
|
+
if (!conn.connected || conn.deviceInfoFresh)
|
|
1036
|
+
return false;
|
|
1037
|
+
if (conn.deviceInfoRequestsSent >= DEVICE_INFO_MAX_REQUESTS)
|
|
1038
|
+
return false;
|
|
1039
|
+
if (now - conn.lastDeviceInfoRequestAt < DEVICE_INFO_RETRY_MS)
|
|
1040
|
+
return false;
|
|
1041
|
+
return true;
|
|
1042
|
+
}
|
|
1043
|
+
function denylistForeignPort(port, reason) {
|
|
1044
|
+
foreignDenylistUntil.set(port, Date.now() + FOREIGN_DENYLIST_COOLDOWN_MS);
|
|
1045
|
+
foreignProbeFailures.delete(port);
|
|
1046
|
+
debug('ESP32', `Denylisting non-AgentDeck port ${port} for ${Math.round(FOREIGN_DENYLIST_COOLDOWN_MS / 60000)}min (${reason})`);
|
|
1047
|
+
}
|
|
1048
|
+
/** @internal Exported for testing only. True if the port is in active cooldown. */
|
|
1049
|
+
export function isForeignPortDenylisted(port, now = Date.now()) {
|
|
1050
|
+
const until = foreignDenylistUntil.get(port);
|
|
1051
|
+
return until != null && until > now;
|
|
1052
|
+
}
|
|
1053
|
+
/** @internal Exported for testing only. Clear all foreign-port tracking state. */
|
|
1054
|
+
export function __resetForeignPortState() {
|
|
1055
|
+
foreignProbeFailures.clear();
|
|
1056
|
+
foreignDenylistUntil.clear();
|
|
1057
|
+
}
|
|
1058
|
+
/** @internal Exported for testing only.
|
|
1059
|
+
* Count a failed identification cycle; denylist once it crosses the threshold. */
|
|
1060
|
+
export function recordForeignProbeFailure(port) {
|
|
1061
|
+
const next = (foreignProbeFailures.get(port) ?? 0) + 1;
|
|
1062
|
+
foreignProbeFailures.set(port, next);
|
|
1063
|
+
if (next >= FOREIGN_MAX_PROBE_FAILURES) {
|
|
1064
|
+
denylistForeignPort(port, `${next} failed probes`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Check for stale connections (no read for >60s) and close them.
|
|
1069
|
+
* This allows re-poll to automatically reconnect devices that stopped responding.
|
|
1070
|
+
*/
|
|
1071
|
+
function checkStaleConnections() {
|
|
1072
|
+
const now = Date.now();
|
|
1073
|
+
let staleCount = 0;
|
|
1074
|
+
for (const conn of connections) {
|
|
1075
|
+
if (!conn.connected)
|
|
1076
|
+
continue;
|
|
1077
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
1078
|
+
const hasRead = conn.lastReadAt > 0;
|
|
1079
|
+
const readAge = hasRead ? now - conn.lastReadAt : now - conn.connectedAt;
|
|
1080
|
+
const writeAge = now - conn.lastWriteAt;
|
|
1081
|
+
// For CDC ports, do not close due to lack of read traffic if write is active and healthy.
|
|
1082
|
+
if (isCDC) {
|
|
1083
|
+
// A held-open CDC port that never identifies is a foreign device; denylist
|
|
1084
|
+
// it so we stop occupying it (CDC ports aren't otherwise reaped on silence).
|
|
1085
|
+
if (isUnidentifiedForeign(conn) && (now - conn.connectedAt) > FOREIGN_CDC_GRACE_MS) {
|
|
1086
|
+
denylistForeignPort(conn.port, 'CDC never identified');
|
|
1087
|
+
closeConnection(conn);
|
|
1088
|
+
staleCount++;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
// A CDC port whose board is identified but has read nothing since connect is
|
|
1092
|
+
// half-open (board reset, RX pipe dead). Recycle the FD so re-poll re-probes;
|
|
1093
|
+
// the reopen's DTR/RTS toggle may itself revive the board. Feed the foreign
|
|
1094
|
+
// probe-failure counter so a permanently-dead board denylists after a few
|
|
1095
|
+
// strikes instead of getting DTR/RTS-reset every grace window.
|
|
1096
|
+
if (isHalfOpenIdentifiedCdc(conn, now)) {
|
|
1097
|
+
debug('ESP32', `Half-open CDC (identified, no read since connect): ${conn.port} — recycling`);
|
|
1098
|
+
recordForeignProbeFailure(conn.port);
|
|
1099
|
+
closeConnection(conn);
|
|
1100
|
+
staleCount++;
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (writeAge > STALE_THRESHOLD_MS) {
|
|
1104
|
+
debug('ESP32', `Stale CDC connection (no write for >60s): ${conn.port}`);
|
|
1105
|
+
closeConnection(conn);
|
|
1106
|
+
staleCount++;
|
|
1107
|
+
}
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
// Identified UART that read before but went RX-silent: the dual clause
|
|
1111
|
+
// below requires writeAge > 60s too, which 5s heartbeat writes prevent
|
|
1112
|
+
// forever. Recycle regardless of write health; re-poll's reopen (DTR/RTS
|
|
1113
|
+
// toggle) resets the board.
|
|
1114
|
+
if (isSilentIdentifiedUart(conn, now)) {
|
|
1115
|
+
debug('ESP32', `Half-open UART (identified, RX silent ${Math.floor(readAge / 1000)}s, writes healthy): ${conn.port} — recycling`);
|
|
1116
|
+
closeConnection(conn);
|
|
1117
|
+
staleCount++;
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
if ((!hasRead && readAge > INITIAL_READ_TIMEOUT_MS) ||
|
|
1121
|
+
(hasRead && readAge > STALE_THRESHOLD_MS && writeAge > STALE_THRESHOLD_MS)) {
|
|
1122
|
+
debug('ESP32', `Stale connection detected: ${conn.port} (${Math.floor(readAge / 1000)}s ${hasRead ? 'since last read' : 'without initial read'}, ${Math.floor(writeAge / 1000)}s since write)`);
|
|
1123
|
+
// A UART port closed before ever identifying counts as a foreign probe
|
|
1124
|
+
// failure; enough failures denylist it so we stop reopening (and resetting) it.
|
|
1125
|
+
if (isUnidentifiedForeign(conn))
|
|
1126
|
+
recordForeignProbeFailure(conn.port);
|
|
1127
|
+
closeConnection(conn);
|
|
1128
|
+
staleCount++;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (staleCount > 0) {
|
|
1132
|
+
// Remove stale connections immediately, then trigger re-poll
|
|
1133
|
+
connections = connections.filter(c => c.connected);
|
|
1134
|
+
debug('ESP32', `Closed ${staleCount} stale connection(s), re-polling...`);
|
|
1135
|
+
pollForDevices().catch(err => {
|
|
1136
|
+
debug('ESP32', `Re-poll after stale cleanup failed: ${err.message}`);
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
321
1139
|
}
|
|
322
1140
|
/**
|
|
323
1141
|
* Start ESP32 serial bridge.
|
|
@@ -328,6 +1146,7 @@ function sendHeartbeat() {
|
|
|
328
1146
|
* USB port (stty stuck in kernel I/O) cannot block bridge startup.
|
|
329
1147
|
*/
|
|
330
1148
|
export function startESP32Serial() {
|
|
1149
|
+
loadDeviceInfoCache();
|
|
331
1150
|
// Fire-and-forget initial detection (non-blocking)
|
|
332
1151
|
pollForDevices().catch(err => {
|
|
333
1152
|
debug('ESP32', `Initial poll failed: ${err.message}`);
|
|
@@ -343,31 +1162,69 @@ export function startESP32Serial() {
|
|
|
343
1162
|
debug('ESP32', 'Serial bridge started');
|
|
344
1163
|
}
|
|
345
1164
|
async function pollForDevices() {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
1165
|
+
if (pollInProgress)
|
|
1166
|
+
return;
|
|
1167
|
+
pollInProgress = true;
|
|
1168
|
+
try {
|
|
1169
|
+
const ports = [...new Set(await detectESP32Ports())];
|
|
1170
|
+
const portSet = new Set(ports);
|
|
1171
|
+
// Forget foreign-port state for ports that are gone (a replug re-probes
|
|
1172
|
+
// fresh) or whose cooldown has elapsed. Then skip ports still in cooldown so
|
|
1173
|
+
// we don't reopen — and reset — a non-AgentDeck device every poll.
|
|
1174
|
+
const nowTs = Date.now();
|
|
1175
|
+
for (const [p, until] of [...foreignDenylistUntil]) {
|
|
1176
|
+
if (until <= nowTs || !portSet.has(p))
|
|
1177
|
+
foreignDenylistUntil.delete(p);
|
|
1178
|
+
}
|
|
1179
|
+
for (const p of [...foreignProbeFailures.keys()]) {
|
|
1180
|
+
if (!portSet.has(p))
|
|
1181
|
+
foreignProbeFailures.delete(p);
|
|
1182
|
+
}
|
|
1183
|
+
const allowedPorts = ports.filter((p) => {
|
|
1184
|
+
const until = foreignDenylistUntil.get(p);
|
|
1185
|
+
if (until && until > nowTs) {
|
|
1186
|
+
debug('ESP32', `Skipping denylisted non-AgentDeck port ${p} (${Math.ceil((until - nowTs) / 1000)}s left)`);
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
return true;
|
|
1190
|
+
});
|
|
1191
|
+
// Remove disconnected or unplugged ports. Read silence alone is handled
|
|
1192
|
+
// separately so half-duplex-but-rendering boards are not reset every minute.
|
|
1193
|
+
connections = connections.filter(c => {
|
|
1194
|
+
if (!c.connected) {
|
|
1195
|
+
closeConnection(c);
|
|
1196
|
+
return false;
|
|
352
1197
|
}
|
|
353
|
-
|
|
1198
|
+
if (!portSet.has(c.port)) {
|
|
1199
|
+
debug('ESP32', `Serial port disappeared: ${c.port}`);
|
|
1200
|
+
closeConnection(c);
|
|
1201
|
+
return false;
|
|
1202
|
+
}
|
|
1203
|
+
return true;
|
|
1204
|
+
});
|
|
1205
|
+
// Add new ports (denylisted foreign ports are excluded via allowedPorts)
|
|
1206
|
+
for (const port of allowedPorts) {
|
|
1207
|
+
if (connections.some(c => c.port === port) || openingPorts.has(port))
|
|
1208
|
+
continue;
|
|
1209
|
+
openingPorts.add(port);
|
|
354
1210
|
try {
|
|
355
|
-
|
|
1211
|
+
const conn = await openPort(port);
|
|
1212
|
+
if (conn && !connections.some(c => c.port === port)) {
|
|
1213
|
+
connections.push(conn);
|
|
1214
|
+
}
|
|
1215
|
+
else if (conn) {
|
|
1216
|
+
conn.connected = false;
|
|
1217
|
+
closeConnection(conn);
|
|
1218
|
+
}
|
|
356
1219
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
return true;
|
|
361
|
-
});
|
|
362
|
-
// Add new ports
|
|
363
|
-
for (const port of ports) {
|
|
364
|
-
if (!connections.some(c => c.port === port)) {
|
|
365
|
-
const conn = await openPort(port);
|
|
366
|
-
if (conn) {
|
|
367
|
-
connections.push(conn);
|
|
1220
|
+
finally {
|
|
1221
|
+
openingPorts.delete(port);
|
|
368
1222
|
}
|
|
369
1223
|
}
|
|
370
1224
|
}
|
|
1225
|
+
finally {
|
|
1226
|
+
pollInProgress = false;
|
|
1227
|
+
}
|
|
371
1228
|
}
|
|
372
1229
|
/**
|
|
373
1230
|
* Broadcast a BridgeEvent to all connected ESP32 devices via serial.
|
|
@@ -377,7 +1234,6 @@ export function broadcastESP32(event) {
|
|
|
377
1234
|
return;
|
|
378
1235
|
if (!FORWARDED_EVENTS.has(event.type))
|
|
379
1236
|
return;
|
|
380
|
-
const prepared = prepareForSerial(event);
|
|
381
1237
|
// Debug: log sessions_list and state_update details for ESP32 troubleshooting
|
|
382
1238
|
if (event.type === 'sessions_list') {
|
|
383
1239
|
const e = event;
|
|
@@ -388,9 +1244,11 @@ export function broadcastESP32(event) {
|
|
|
388
1244
|
const e = event;
|
|
389
1245
|
debug('ESP32', `→ state_update: agent=${e.agentType} state=${e.state}`);
|
|
390
1246
|
}
|
|
391
|
-
const json = JSON.stringify(prepared);
|
|
392
1247
|
for (const conn of connections) {
|
|
393
|
-
|
|
1248
|
+
if (event.type === 'sessions_list' && !hasLiveDeviceInfo(conn))
|
|
1249
|
+
continue;
|
|
1250
|
+
const prepared = prepareForSerial(event, conn);
|
|
1251
|
+
sendToConnection(conn, JSON.stringify(prepared));
|
|
394
1252
|
}
|
|
395
1253
|
}
|
|
396
1254
|
/**
|
|
@@ -400,7 +1258,7 @@ export function sendWifiProvision(port, msg) {
|
|
|
400
1258
|
const conn = connections.find(c => c.port === port && c.connected);
|
|
401
1259
|
if (!conn)
|
|
402
1260
|
return false;
|
|
403
|
-
sendToConnection(conn, JSON.stringify(msg));
|
|
1261
|
+
sendToConnection(conn, JSON.stringify(msg), true);
|
|
404
1262
|
conn.provisionSent = true;
|
|
405
1263
|
debug('ESP32', `→ ${port}: wifi_provision (SSID: ${msg.ssid})`);
|
|
406
1264
|
return true;
|
|
@@ -411,31 +1269,76 @@ export function sendWifiProvision(port, msg) {
|
|
|
411
1269
|
export function sendWifiProvisionToAll(msg) {
|
|
412
1270
|
let count = 0;
|
|
413
1271
|
for (const conn of connections) {
|
|
414
|
-
if (!conn
|
|
415
|
-
continue;
|
|
416
|
-
// Skip if already provisioned or WiFi already configured
|
|
417
|
-
if (conn.provisionSent)
|
|
1272
|
+
if (!shouldSendWifiProvision(conn))
|
|
418
1273
|
continue;
|
|
419
|
-
|
|
420
|
-
continue;
|
|
421
|
-
sendToConnection(conn, JSON.stringify(msg));
|
|
1274
|
+
sendToConnection(conn, JSON.stringify(msg), true);
|
|
422
1275
|
conn.provisionSent = true;
|
|
423
1276
|
count++;
|
|
424
1277
|
debug('ESP32', `→ ${conn.port}: wifi_provision (SSID: ${msg.ssid})`);
|
|
425
1278
|
}
|
|
426
1279
|
return count;
|
|
427
1280
|
}
|
|
1281
|
+
/** @internal Exported for testing only */
|
|
1282
|
+
export function shouldSendWifiProvision(conn) {
|
|
1283
|
+
if (!conn.connected)
|
|
1284
|
+
return false;
|
|
1285
|
+
if (!conn.deviceInfo?.board)
|
|
1286
|
+
return false;
|
|
1287
|
+
if (conn.provisionSent)
|
|
1288
|
+
return false;
|
|
1289
|
+
// IPS10 carries a daemon endpoint in NVS in addition to WiFi credentials.
|
|
1290
|
+
// Refresh it once while USB serial is active and the radio is online, even
|
|
1291
|
+
// if the board is already configured/connected; otherwise a stale endpoint
|
|
1292
|
+
// can survive and WiFi-only operation keeps dialing the old daemon host.
|
|
1293
|
+
if (conn.deviceInfo.board === 'ips_10' && conn.deviceInfo.wifiConnected && !conn.deviceInfo.wifiRadioParked) {
|
|
1294
|
+
return true;
|
|
1295
|
+
}
|
|
1296
|
+
if (conn.deviceInfo.wifiConnected)
|
|
1297
|
+
return false;
|
|
1298
|
+
// Auto-provision is for first setup. A configured board may intentionally have
|
|
1299
|
+
// WiFi off (IPS10 USB-primary radio parking) or may be temporarily away from
|
|
1300
|
+
// its AP; repeatedly injecting credentials would fight firmware policy.
|
|
1301
|
+
if (conn.deviceInfo.wifiConfigured)
|
|
1302
|
+
return false;
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
428
1305
|
/**
|
|
429
1306
|
* Get device info for all connected ESP32 devices.
|
|
430
1307
|
*/
|
|
431
1308
|
export function getESP32DeviceInfo() {
|
|
1309
|
+
const now = Date.now();
|
|
432
1310
|
return connections
|
|
433
|
-
.filter(c => c
|
|
1311
|
+
.filter(c => isResponsive(c, now))
|
|
434
1312
|
.map(c => ({
|
|
435
1313
|
port: c.port,
|
|
436
1314
|
...c.deviceInfo,
|
|
437
1315
|
}));
|
|
438
1316
|
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Board identities currently reachable over a LIVE, responsive USB serial
|
|
1319
|
+
* connection (has announced device_info and is actively read/written). The
|
|
1320
|
+
* daemon uses this to enforce single-path communication: when a physical board
|
|
1321
|
+
* is driven over serial, its WiFi WebSocket copy is redundant and is suppressed
|
|
1322
|
+
* (see daemon-server transport dedup). A power-only USB (no enumerated port) or
|
|
1323
|
+
* a silent/booting port is NOT responsive, so it never appears here and the
|
|
1324
|
+
* board keeps using WiFi — exactly the desired fallback.
|
|
1325
|
+
*
|
|
1326
|
+
* `ip` is the board's own WiFi IP (when connected), used to confirm the serial
|
|
1327
|
+
* connection and the WiFi socket are the same physical unit before deduping.
|
|
1328
|
+
*/
|
|
1329
|
+
export function getSerialReachableBoards() {
|
|
1330
|
+
const now = Date.now();
|
|
1331
|
+
const out = [];
|
|
1332
|
+
for (const c of connections) {
|
|
1333
|
+
if (!isResponsive(c, now))
|
|
1334
|
+
continue;
|
|
1335
|
+
const board = c.deviceInfo?.board;
|
|
1336
|
+
if (!board)
|
|
1337
|
+
continue;
|
|
1338
|
+
out.push({ board, ip: c.deviceInfo?.wifiConnected ? c.deviceInfo?.ip : undefined });
|
|
1339
|
+
}
|
|
1340
|
+
return out;
|
|
1341
|
+
}
|
|
439
1342
|
/**
|
|
440
1343
|
* Wake recovery — close stale file descriptors, force immediate re-poll.
|
|
441
1344
|
*/
|
|
@@ -443,14 +1346,7 @@ export function handleESP32Wake() {
|
|
|
443
1346
|
debug('ESP32', `Wake recovery — closing ${connections.length} stale connection(s)`);
|
|
444
1347
|
for (const conn of connections) {
|
|
445
1348
|
conn.connected = false;
|
|
446
|
-
|
|
447
|
-
conn.stream.end();
|
|
448
|
-
}
|
|
449
|
-
catch { /* ignore */ }
|
|
450
|
-
try {
|
|
451
|
-
conn.reader?.destroy();
|
|
452
|
-
}
|
|
453
|
-
catch { /* ignore */ }
|
|
1349
|
+
closeConnection(conn);
|
|
454
1350
|
}
|
|
455
1351
|
connections = [];
|
|
456
1352
|
// Delay re-poll 2s to let USB bus stabilize after wake
|
|
@@ -474,14 +1370,7 @@ export function stopESP32Serial() {
|
|
|
474
1370
|
}
|
|
475
1371
|
for (const conn of connections) {
|
|
476
1372
|
conn.connected = false;
|
|
477
|
-
|
|
478
|
-
conn.stream.end();
|
|
479
|
-
}
|
|
480
|
-
catch { /* ignore */ }
|
|
481
|
-
try {
|
|
482
|
-
conn.reader?.destroy();
|
|
483
|
-
}
|
|
484
|
-
catch { /* ignore */ }
|
|
1373
|
+
closeConnection(conn);
|
|
485
1374
|
}
|
|
486
1375
|
connections = [];
|
|
487
1376
|
messageHandler = null;
|
|
@@ -491,12 +1380,57 @@ export function stopESP32Serial() {
|
|
|
491
1380
|
* Get number of connected ESP32 devices.
|
|
492
1381
|
*/
|
|
493
1382
|
export function esp32ConnectionCount() {
|
|
494
|
-
|
|
1383
|
+
const now = Date.now();
|
|
1384
|
+
return connections.filter(c => isResponsive(c, now)).length;
|
|
495
1385
|
}
|
|
496
1386
|
/**
|
|
497
1387
|
* Get list of connected ESP32 serial port paths.
|
|
498
1388
|
*/
|
|
499
1389
|
export function getESP32Ports() {
|
|
500
|
-
|
|
1390
|
+
const now = Date.now();
|
|
1391
|
+
return connections.filter(c => isResponsive(c, now)).map(c => c.port);
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Get detailed connection status for all ESP32 devices.
|
|
1395
|
+
* Used by /api/status endpoint to show connection health.
|
|
1396
|
+
*/
|
|
1397
|
+
export function getSerialConnectionStatus() {
|
|
1398
|
+
const now = Date.now();
|
|
1399
|
+
return connections.map(c => ({
|
|
1400
|
+
port: c.port,
|
|
1401
|
+
connected: isResponsive(c, now),
|
|
1402
|
+
transportOpen: c.connected,
|
|
1403
|
+
board: c.deviceInfo?.board,
|
|
1404
|
+
version: c.deviceInfo?.version,
|
|
1405
|
+
buildHash: c.deviceInfo?.buildHash,
|
|
1406
|
+
buildEpoch: c.deviceInfo?.buildEpoch,
|
|
1407
|
+
wifiConfigured: c.deviceInfo?.wifiConfigured,
|
|
1408
|
+
wifiConnected: c.deviceInfo?.wifiConnected,
|
|
1409
|
+
wifiRadioParked: c.deviceInfo?.wifiRadioParked,
|
|
1410
|
+
uptimeSec: c.deviceInfo?.uptimeSec,
|
|
1411
|
+
resetReason: c.deviceInfo?.resetReason,
|
|
1412
|
+
resetReasonCode: c.deviceInfo?.resetReasonCode,
|
|
1413
|
+
otaSupported: c.deviceInfo?.otaSupported,
|
|
1414
|
+
otaSlotCount: c.deviceInfo?.otaSlotCount,
|
|
1415
|
+
otaSlotSize: c.deviceInfo?.otaSlotSize,
|
|
1416
|
+
otaFreeSketchSpace: c.deviceInfo?.otaFreeSketchSpace,
|
|
1417
|
+
otaReason: c.deviceInfo?.otaReason,
|
|
1418
|
+
timelineCount: c.deviceInfo?.timelineCount,
|
|
1419
|
+
sessionCount: c.deviceInfo?.sessionCount,
|
|
1420
|
+
usageFiveH: c.deviceInfo?.usageFiveH,
|
|
1421
|
+
processingCount: c.deviceInfo?.processingCount,
|
|
1422
|
+
lastReadAt: c.lastReadAt,
|
|
1423
|
+
lastWriteAt: c.lastWriteAt,
|
|
1424
|
+
lastReadSecondsAgo: c.lastReadAt > 0 ? Math.floor((now - c.lastReadAt) / 1000) : null,
|
|
1425
|
+
lastWriteSecondsAgo: Math.floor((now - c.lastWriteAt) / 1000),
|
|
1426
|
+
stale: c.connected && !isResponsive(c, now),
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
export function getSerialLastError() {
|
|
1430
|
+
if (lastErrorByPort.size === 0)
|
|
1431
|
+
return null;
|
|
1432
|
+
return Array.from(lastErrorByPort.entries())
|
|
1433
|
+
.map(([port, error]) => `${port}: ${error}`)
|
|
1434
|
+
.join('; ');
|
|
501
1435
|
}
|
|
502
1436
|
//# sourceMappingURL=esp32-serial.js.map
|