@agentdeck/bridge 0.2.1 → 0.2.3
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/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 +129 -6
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +470 -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 +87 -11
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +43 -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 +135 -9
- package/dist/apme/index.js.map +1 -1
- 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 +92 -4
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +586 -70
- 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 +14 -10
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +34 -8
- 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 +99 -0
- package/dist/awaiting-overlay.d.ts.map +1 -0
- package/dist/awaiting-overlay.js +148 -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 +24 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +157 -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/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1025 -94
- 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/daemon-server.d.ts +85 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +1627 -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 +160 -1
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +950 -153
- 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 +112 -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 +22 -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 +215 -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.map +1 -1
- package/dist/logger.js +12 -3
- 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/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/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/types.d.ts +4 -2
- package/dist/modules/types.d.ts.map +1 -1
- 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/output-parser.d.ts +30 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +125 -9
- package/dist/output-parser.js.map +1 -1
- package/dist/passive-observer.d.ts +26 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +258 -49
- package/dist/passive-observer.js.map +1 -1
- package/dist/permission-resolver.d.ts +47 -0
- package/dist/permission-resolver.d.ts.map +1 -0
- package/dist/permission-resolver.js +103 -0
- package/dist/permission-resolver.js.map +1 -0
- package/dist/pixoo/micro-glyphs.d.ts +49 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
- package/dist/pixoo/micro-glyphs.js +276 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -0
- package/dist/pixoo/pixoo-bridge.d.ts +2 -2
- package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
- package/dist/pixoo/pixoo-bridge.js +93 -25
- 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 +8 -3
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +189 -60
- 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 +60 -11
- package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
- package/dist/pixoo/pixoo-sprites.js +512 -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/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 +17 -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-registry.d.ts +71 -3
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +137 -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 +63 -3
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +292 -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/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 +24 -7
- 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 +303 -119
- package/dist/tui/renderer.js.map +1 -1
- package/dist/tui/terrarium.d.ts.map +1 -1
- package/dist/tui/terrarium.js +37 -49
- 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 +74 -12
- 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/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 +22 -1
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +89 -4
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +112 -0
- package/package.json +9 -3
- 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/d200h/hid-protocol.d.ts +0 -62
- package/dist/d200h/hid-protocol.d.ts.map +0 -1
- package/dist/d200h/hid-protocol.js +0 -124
- package/dist/d200h/hid-protocol.js.map +0 -1
- package/dist/d200h/image-renderer.d.ts +0 -36
- package/dist/d200h/image-renderer.d.ts.map +0 -1
- package/dist/d200h/image-renderer.js +0 -487
- package/dist/d200h/image-renderer.js.map +0 -1
- package/dist/modules/d200h-module.d.ts +0 -43
- package/dist/modules/d200h-module.d.ts.map +0 -1
- package/dist/modules/d200h-module.js +0 -360
- package/dist/modules/d200h-module.js.map +0 -1
- package/dist/modules/d200h-renderer.d.ts +0 -7
- package/dist/modules/d200h-renderer.d.ts.map +0 -1
- package/dist/modules/d200h-renderer.js +0 -321
- package/dist/modules/d200h-renderer.js.map +0 -1
package/dist/esp32-serial.js
CHANGED
|
@@ -10,33 +10,113 @@
|
|
|
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
19
|
import { debug } 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
|
+
const SERIAL_OPEN_PROBE_TIMEOUT_MS = 1500;
|
|
54
|
+
const SERIAL_WRITE_INTERVAL_MS = 120;
|
|
55
|
+
const SERIAL_MAX_QUEUE = 24;
|
|
56
|
+
const SERIAL_KEEPALIVE_JSON = JSON.stringify({ type: 'serial_keepalive' });
|
|
57
|
+
const SERIAL_OPEN_PROBE_SCRIPT = `
|
|
58
|
+
const fs = require('fs');
|
|
59
|
+
const port = process.argv[1];
|
|
60
|
+
let fd = -1;
|
|
61
|
+
try {
|
|
62
|
+
fd = fs.openSync(port, fs.constants.O_RDWR | (fs.constants.O_NOCTTY || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
63
|
+
fs.closeSync(fd);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (fd >= 0) {
|
|
67
|
+
try { fs.closeSync(fd); } catch {}
|
|
68
|
+
}
|
|
69
|
+
console.error(err && err.message ? err.message : String(err));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
31
73
|
let connections = [];
|
|
32
74
|
let pollTimer = null;
|
|
33
75
|
let heartbeatTimer = null;
|
|
76
|
+
let pollInProgress = false;
|
|
77
|
+
const openingPorts = new Set();
|
|
78
|
+
const lastKnownDeviceInfoByPort = new Map();
|
|
79
|
+
const lastErrorByPort = new Map();
|
|
80
|
+
// Foreign (non-AgentDeck) serial ports: failure count + denylist-until timestamp.
|
|
81
|
+
const foreignProbeFailures = new Map();
|
|
82
|
+
const foreignDenylistUntil = new Map();
|
|
83
|
+
let deviceInfoCacheLoaded = false;
|
|
34
84
|
let stateProvider = null;
|
|
35
85
|
let usageProvider = null;
|
|
86
|
+
let displayStateProvider = null;
|
|
87
|
+
let sessionsListProvider = null;
|
|
36
88
|
let initialStateProvider = null;
|
|
37
89
|
let messageHandler = null;
|
|
38
90
|
// Events to forward — shared constant from @agentdeck/shared
|
|
39
91
|
const FORWARDED_EVENTS = SERIAL_FORWARDED_EVENTS;
|
|
92
|
+
function deviceInfoCachePath() {
|
|
93
|
+
return join(process.env.AGENTDECK_DATA_DIR || join(homedir(), '.agentdeck'), 'esp32-device-cache.json');
|
|
94
|
+
}
|
|
95
|
+
function loadDeviceInfoCache() {
|
|
96
|
+
if (deviceInfoCacheLoaded)
|
|
97
|
+
return;
|
|
98
|
+
deviceInfoCacheLoaded = true;
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(readFileSync(deviceInfoCachePath(), 'utf-8'));
|
|
101
|
+
for (const [port, info] of Object.entries(parsed)) {
|
|
102
|
+
if (info?.board)
|
|
103
|
+
lastKnownDeviceInfoByPort.set(port, info);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Missing cache is normal.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function persistDeviceInfoCache() {
|
|
111
|
+
try {
|
|
112
|
+
const path = deviceInfoCachePath();
|
|
113
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
114
|
+
writeFileSync(path, JSON.stringify(Object.fromEntries(lastKnownDeviceInfoByPort), null, 2), 'utf-8');
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
debug('ESP32', `Failed to persist device cache: ${String(err)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
40
120
|
// ESP32 without WiFi/NTP can't parse ISO dates — shared formatResetTime
|
|
41
121
|
// handles undefined and pre-formatted strings.
|
|
42
122
|
/**
|
|
@@ -45,28 +125,208 @@ const FORWARDED_EVENTS = SERIAL_FORWARDED_EVENTS;
|
|
|
45
125
|
* - Strip fields the ESP32 firmware doesn't parse (reduce size for small RX buffers)
|
|
46
126
|
*/
|
|
47
127
|
/** @internal Exported for testing only */
|
|
48
|
-
|
|
128
|
+
// Firmware (handleSessionsList) only stores the first N sessions; both sides
|
|
129
|
+
// must agree on the same cap. Keep in sync with esp32/src/net/protocol.cpp
|
|
130
|
+
// (sessions[10] / MOSAIC_MAX / min(...,10)).
|
|
131
|
+
export const SERIAL_SESSIONS_CAP = 10;
|
|
132
|
+
/**
|
|
133
|
+
* Pick which sessions survive the serial session cap.
|
|
134
|
+
*
|
|
135
|
+
* A naive `slice(0, cap)` lets a single agent type (e.g. several idle Claude Code
|
|
136
|
+
* sessions) fill every slot and starve other agents — a running Codex/OpenClaw
|
|
137
|
+
* session that lands past the cap never reaches the firmware's creature renderer.
|
|
138
|
+
*
|
|
139
|
+
* Instead we round-robin across agent types so each present type claims at least
|
|
140
|
+
* one slot before any type takes a second. Within a type, live + active
|
|
141
|
+
* (non-idle) sessions are preferred so a processing session is never dropped in
|
|
142
|
+
* favour of an idle sibling. Order within a type is otherwise preserved (stable).
|
|
143
|
+
*/
|
|
144
|
+
export function roundRobinByAgentType(sessions, cap) {
|
|
145
|
+
if (sessions.length <= cap)
|
|
146
|
+
return sessions;
|
|
147
|
+
const groups = new Map();
|
|
148
|
+
const order = [];
|
|
149
|
+
for (const s of sessions) {
|
|
150
|
+
const key = typeof s?.agentType === 'string' ? s.agentType : '';
|
|
151
|
+
let bucket = groups.get(key);
|
|
152
|
+
if (!bucket) {
|
|
153
|
+
bucket = [];
|
|
154
|
+
groups.set(key, bucket);
|
|
155
|
+
order.push(key);
|
|
156
|
+
}
|
|
157
|
+
bucket.push(s);
|
|
158
|
+
}
|
|
159
|
+
// Within each type: alive first, then active (non-idle) first. Stable for ties.
|
|
160
|
+
const rank = (s) => {
|
|
161
|
+
const alive = s?.alive ? 1 : 0;
|
|
162
|
+
const state = typeof s?.state === 'string' ? s.state : '';
|
|
163
|
+
const active = state && state !== 'idle' ? 1 : 0;
|
|
164
|
+
return alive * 2 + active;
|
|
165
|
+
};
|
|
166
|
+
for (const key of order) {
|
|
167
|
+
groups.get(key).sort((a, b) => rank(b) - rank(a));
|
|
168
|
+
}
|
|
169
|
+
const result = [];
|
|
170
|
+
let progressed = true;
|
|
171
|
+
while (result.length < cap && progressed) {
|
|
172
|
+
progressed = false;
|
|
173
|
+
for (const key of order) {
|
|
174
|
+
const bucket = groups.get(key);
|
|
175
|
+
if (bucket.length === 0)
|
|
176
|
+
continue;
|
|
177
|
+
result.push(bucket.shift());
|
|
178
|
+
progressed = true;
|
|
179
|
+
if (result.length >= cap)
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
export function prepareForSerial(event, _conn) {
|
|
49
186
|
const e = event;
|
|
50
187
|
if (event.type === 'usage_update') {
|
|
51
|
-
//
|
|
52
|
-
|
|
188
|
+
// WHITELIST of the fields the firmware actually parses (protocol.cpp
|
|
189
|
+
// handleUsageUpdate). The old blocklist approach silently leaked every
|
|
190
|
+
// NEW usage field onto the wire — modelCatalog alone (3.2KB) pushed the
|
|
191
|
+
// line past the firmware's 4096-byte line buffer, so boards discarded
|
|
192
|
+
// EVERY usage_update and their gauges froze on stale values.
|
|
193
|
+
const cx = e.codexRateLimits
|
|
194
|
+
? {
|
|
195
|
+
primary: e.codexRateLimits.primary
|
|
196
|
+
? { usedPercent: e.codexRateLimits.primary.usedPercent, resetsAt: formatResetTime(e.codexRateLimits.primary.resetsAt) }
|
|
197
|
+
: undefined,
|
|
198
|
+
secondary: e.codexRateLimits.secondary
|
|
199
|
+
? { usedPercent: e.codexRateLimits.secondary.usedPercent, resetsAt: formatResetTime(e.codexRateLimits.secondary.resetsAt) }
|
|
200
|
+
: undefined,
|
|
201
|
+
}
|
|
202
|
+
: undefined;
|
|
203
|
+
// Subscriptions carry an ISO `until`; a serial device has no reliable
|
|
204
|
+
// clock, so pre-format to a short "~M/D" the panel can render as-is.
|
|
205
|
+
const subs = Array.isArray(e.subscriptions)
|
|
206
|
+
? e.subscriptions.map((sub) => ({
|
|
207
|
+
name: limitString(sub.name, 27),
|
|
208
|
+
until: formatShortDate(sub.until),
|
|
209
|
+
})).filter((sub) => sub.name)
|
|
210
|
+
: undefined;
|
|
211
|
+
const ag = e.antigravityStatus
|
|
212
|
+
? { planName: limitString(e.antigravityStatus.planName, 23), availableCredits: e.antigravityStatus.availableCredits }
|
|
213
|
+
: undefined;
|
|
53
214
|
return {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
215
|
+
type: 'usage_update',
|
|
216
|
+
fiveHourPercent: e.fiveHourPercent,
|
|
217
|
+
sevenDayPercent: e.sevenDayPercent,
|
|
218
|
+
fiveHourResetsAt: formatResetTime(e.fiveHourResetsAt),
|
|
219
|
+
sevenDayResetsAt: formatResetTime(e.sevenDayResetsAt),
|
|
220
|
+
inputTokens: e.inputTokens,
|
|
221
|
+
outputTokens: e.outputTokens,
|
|
222
|
+
toolCalls: e.toolCalls,
|
|
223
|
+
sessionDurationSec: e.sessionDurationSec,
|
|
224
|
+
estimatedCostUsd: e.estimatedCostUsd,
|
|
225
|
+
usageStale: e.usageStale,
|
|
226
|
+
...(cx ? { codexRateLimits: cx } : {}),
|
|
227
|
+
...(subs ? { subscriptions: subs } : {}),
|
|
228
|
+
...(ag ? { antigravityStatus: ag } : {}),
|
|
57
229
|
};
|
|
58
230
|
}
|
|
59
231
|
if (event.type === 'state_update') {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
232
|
+
return {
|
|
233
|
+
type: 'state_update',
|
|
234
|
+
state: e.state,
|
|
235
|
+
permissionMode: e.permissionMode,
|
|
236
|
+
agentType: limitString(e.agentType, 15),
|
|
237
|
+
currentTool: limitString(e.currentTool, 39),
|
|
238
|
+
toolInput: limitString(e.toolInput, 79),
|
|
239
|
+
projectName: limitString(e.projectName, 39),
|
|
240
|
+
modelName: limitString(e.modelName, 31),
|
|
241
|
+
effortLevel: limitString(e.effortLevel, 7),
|
|
242
|
+
promptType: limitString(e.promptType, 19),
|
|
243
|
+
question: limitString(e.question, 199),
|
|
244
|
+
options: sanitizeOptions(e.options),
|
|
245
|
+
gatewayAvailable: Boolean(e.gatewayAvailable),
|
|
246
|
+
gatewayConnected: Boolean(e.gatewayConnected),
|
|
247
|
+
gatewayHasError: Boolean(e.gatewayHasError),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (event.type === 'timeline_event' || event.type === 'timeline_history') {
|
|
251
|
+
// Panels have no local timezone — attach host-local "HH:MM" so the
|
|
252
|
+
// InkDeck ticker (and future e-ink timelines) shows wall-clock time.
|
|
253
|
+
const stamp = (entry) => {
|
|
254
|
+
if (!entry || !Number.isFinite(entry.ts))
|
|
255
|
+
return entry;
|
|
256
|
+
const d = new Date(entry.ts);
|
|
257
|
+
return {
|
|
258
|
+
...entry,
|
|
259
|
+
// Bound to the firmware's TimelineEntry buffers (raw[120]/detail[200])
|
|
260
|
+
// so a history seed with long chat bodies can't balloon the line.
|
|
261
|
+
raw: limitString(entry.raw, 119) ?? '',
|
|
262
|
+
detail: limitString(entry.detail, 199),
|
|
263
|
+
localHm: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
|
264
|
+
};
|
|
265
|
+
};
|
|
266
|
+
if (event.type === 'timeline_event')
|
|
267
|
+
return { ...e, entry: stamp(e.entry) };
|
|
268
|
+
return { ...e, entries: Array.isArray(e.entries) ? e.entries.map(stamp) : e.entries };
|
|
269
|
+
}
|
|
270
|
+
if (event.type === 'sessions_list') {
|
|
271
|
+
const raw = Array.isArray(e.sessions) ? e.sessions : [];
|
|
272
|
+
return {
|
|
273
|
+
type: 'sessions_list',
|
|
274
|
+
sessions: roundRobinByAgentType(raw, SERIAL_SESSIONS_CAP).map((s) => ({
|
|
275
|
+
id: limitString(s.id, 31),
|
|
276
|
+
projectName: limitString(s.projectName, 39),
|
|
277
|
+
modelName: limitString(s.modelName, 31),
|
|
278
|
+
agentType: limitString(s.agentType, 15),
|
|
279
|
+
state: limitString(s.state, 19),
|
|
280
|
+
port: Number.isFinite(s.port) ? s.port : 0,
|
|
281
|
+
alive: Boolean(s.alive),
|
|
282
|
+
// Per-session detail for the IPS10 D1 mosaic (cells show tool/elapsed,
|
|
283
|
+
// and awaiting cells render the real option buttons Claude reported).
|
|
284
|
+
currentTool: limitString(s.currentTool, 39),
|
|
285
|
+
promptType: limitString(s.promptType, 19),
|
|
286
|
+
question: limitString(s.question, 159),
|
|
287
|
+
elapsedSec: Number.isFinite(s.elapsedSec) ? Math.round(s.elapsedSec) : undefined,
|
|
288
|
+
// Shared activity one-liner — the glanceable "what is it doing" line
|
|
289
|
+
// (InkDeck session cards render it; other boards ignore it).
|
|
290
|
+
activity: limitString(s.activity, 79),
|
|
291
|
+
options: sanitizeOptions(s.options),
|
|
292
|
+
})),
|
|
293
|
+
};
|
|
63
294
|
}
|
|
64
295
|
return event;
|
|
65
296
|
}
|
|
297
|
+
function limitString(value, max) {
|
|
298
|
+
if (typeof value !== 'string')
|
|
299
|
+
return undefined;
|
|
300
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
301
|
+
}
|
|
302
|
+
/** ISO date → short "~M/D" for clock-less serial panels ('' when absent/invalid). */
|
|
303
|
+
function formatShortDate(iso) {
|
|
304
|
+
if (typeof iso !== 'string' || !iso)
|
|
305
|
+
return '';
|
|
306
|
+
const d = new Date(iso);
|
|
307
|
+
if (isNaN(d.getTime()))
|
|
308
|
+
return '';
|
|
309
|
+
return `~${d.getMonth() + 1}/${d.getDate()}`;
|
|
310
|
+
}
|
|
311
|
+
function sanitizeOptions(options) {
|
|
312
|
+
if (!Array.isArray(options))
|
|
313
|
+
return undefined;
|
|
314
|
+
return options.slice(0, 8).map((o, index) => ({
|
|
315
|
+
label: limitString(o?.label, 79) ?? '',
|
|
316
|
+
shortcut: limitString(o?.shortcut, 39),
|
|
317
|
+
index: Number.isFinite(o?.index) ? o.index : index,
|
|
318
|
+
recommended: Boolean(o?.recommended),
|
|
319
|
+
selected: Boolean(o?.selected),
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
66
322
|
/** Run a shell command with timeout, escalating to SIGKILL if SIGTERM fails. */
|
|
67
323
|
function execWithKill(cmd, timeoutMs = 3000) {
|
|
68
324
|
return new Promise((resolve, reject) => {
|
|
325
|
+
let settled = false;
|
|
69
326
|
const child = exec(cmd, { encoding: 'utf-8', timeout: timeoutMs }, (err, stdout) => {
|
|
327
|
+
if (settled)
|
|
328
|
+
return;
|
|
329
|
+
settled = true;
|
|
70
330
|
if (err) {
|
|
71
331
|
reject(err);
|
|
72
332
|
}
|
|
@@ -74,17 +334,94 @@ function execWithKill(cmd, timeoutMs = 3000) {
|
|
|
74
334
|
resolve(stdout);
|
|
75
335
|
}
|
|
76
336
|
});
|
|
77
|
-
//
|
|
78
|
-
//
|
|
337
|
+
// Serial ioctl can enter uninterruptible kernel I/O on broken CDC ports.
|
|
338
|
+
// On timeout, unblock our promise immediately instead of waiting for exit.
|
|
79
339
|
const killTimer = setTimeout(() => {
|
|
340
|
+
if (settled)
|
|
341
|
+
return;
|
|
342
|
+
settled = true;
|
|
80
343
|
try {
|
|
81
344
|
child.kill('SIGKILL');
|
|
82
345
|
}
|
|
83
346
|
catch { /* already dead */ }
|
|
347
|
+
reject(new Error(`command timed out: ${cmd}`));
|
|
84
348
|
}, timeoutMs + 1000);
|
|
85
349
|
child.on('exit', () => clearTimeout(killTimer));
|
|
86
350
|
});
|
|
87
351
|
}
|
|
352
|
+
function execFileWithKill(file, args, timeoutMs) {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
let settled = false;
|
|
355
|
+
let killTimer = null;
|
|
356
|
+
const child = execFile(file, args, { encoding: 'utf-8', maxBuffer: 64 * 1024 }, (err, stdout, stderr) => {
|
|
357
|
+
if (settled)
|
|
358
|
+
return;
|
|
359
|
+
settled = true;
|
|
360
|
+
if (killTimer)
|
|
361
|
+
clearTimeout(killTimer);
|
|
362
|
+
if (err) {
|
|
363
|
+
const message = String(stderr || '').trim() || err.message;
|
|
364
|
+
reject(new Error(message));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
killTimer = setTimeout(() => {
|
|
371
|
+
if (settled)
|
|
372
|
+
return;
|
|
373
|
+
settled = true;
|
|
374
|
+
try {
|
|
375
|
+
child.kill('SIGKILL');
|
|
376
|
+
}
|
|
377
|
+
catch { /* already dead */ }
|
|
378
|
+
reject(new Error(`timed out after ${timeoutMs}ms`));
|
|
379
|
+
}, timeoutMs);
|
|
380
|
+
child.on('exit', () => {
|
|
381
|
+
if (killTimer)
|
|
382
|
+
clearTimeout(killTimer);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
async function probePortOpenable(port) {
|
|
387
|
+
try {
|
|
388
|
+
await execFileWithKill(process.execPath, ['-e', SERIAL_OPEN_PROBE_SCRIPT, port], SERIAL_OPEN_PROBE_TIMEOUT_MS);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
392
|
+
throw new Error(`open probe failed: ${message}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function openFdWithProbe(port) {
|
|
396
|
+
// Isolate potentially wedged macOS CDC/serial open calls in a short-lived
|
|
397
|
+
// process. Promise.race around fs.open() in this daemon can leave late file
|
|
398
|
+
// descriptors owned by the daemon after timeout, permanently blocking polls.
|
|
399
|
+
await probePortOpenable(port);
|
|
400
|
+
return openSync(port, fsConstants.O_RDWR | fsConstants.O_NOCTTY | fsConstants.O_NONBLOCK);
|
|
401
|
+
}
|
|
402
|
+
function readFromFd(fd, buf) {
|
|
403
|
+
return new Promise((resolve, reject) => {
|
|
404
|
+
read(fd, buf, 0, buf.length, null, (err, bytesRead) => {
|
|
405
|
+
if (err)
|
|
406
|
+
reject(err);
|
|
407
|
+
else
|
|
408
|
+
resolve({ bytesRead });
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
function writeToFd(fd, data) {
|
|
413
|
+
return new Promise((resolve, reject) => {
|
|
414
|
+
write(fd, data, (err) => {
|
|
415
|
+
if (err)
|
|
416
|
+
reject(err);
|
|
417
|
+
else
|
|
418
|
+
resolve();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function closeFd(fd) {
|
|
423
|
+
close(fd, () => { });
|
|
424
|
+
}
|
|
88
425
|
async function detectESP32Ports() {
|
|
89
426
|
try {
|
|
90
427
|
const platform = process.platform;
|
|
@@ -104,12 +441,57 @@ async function detectESP32Ports() {
|
|
|
104
441
|
if (EXCLUDE_PATTERNS.some(p => p.test(port)))
|
|
105
442
|
return false;
|
|
106
443
|
return ESP32_PORT_PATTERNS.some(p => p.test(port));
|
|
107
|
-
});
|
|
444
|
+
}).sort(compareSerialPorts);
|
|
108
445
|
}
|
|
109
|
-
catch {
|
|
446
|
+
catch (err) {
|
|
447
|
+
lastErrorByPort.set('detect', `port detection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
448
|
return [];
|
|
111
449
|
}
|
|
112
450
|
}
|
|
451
|
+
function compareSerialPorts(a, b) {
|
|
452
|
+
const aNative = /usbmodem|ttyACM/.test(a);
|
|
453
|
+
const bNative = /usbmodem|ttyACM/.test(b);
|
|
454
|
+
if (aNative !== bNative)
|
|
455
|
+
return aNative ? 1 : -1;
|
|
456
|
+
return a.localeCompare(b);
|
|
457
|
+
}
|
|
458
|
+
function serialError(port, message) {
|
|
459
|
+
lastErrorByPort.set(port, message);
|
|
460
|
+
debug('ESP32', message);
|
|
461
|
+
}
|
|
462
|
+
function isResponsive(conn, now = Date.now()) {
|
|
463
|
+
if (!conn.connected)
|
|
464
|
+
return false;
|
|
465
|
+
const hasRead = conn.lastReadAt > 0;
|
|
466
|
+
if (hasRead && (now - conn.lastReadAt) <= STALE_THRESHOLD_MS) {
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
// Fallback for native USB CDC ports: if we have written to it successfully recently,
|
|
470
|
+
// and it is open, consider it responsive. This prevents native CDC displays (which may
|
|
471
|
+
// not send read data due to DTR/RTS line states in raw fs mode) from being marked disconnected.
|
|
472
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
473
|
+
if (isCDC && conn.lastWriteAt > 0 && (now - conn.lastWriteAt) <= STALE_THRESHOLD_MS) {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
// If no read has occurred yet, allow an initial grace period
|
|
477
|
+
if (conn.lastReadAt <= 0) {
|
|
478
|
+
return (now - conn.connectedAt) <= INITIAL_READ_TIMEOUT_MS;
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
/** @internal Exported for testing only */
|
|
483
|
+
export function isRetryableSerialIoError(err) {
|
|
484
|
+
const code = err?.code;
|
|
485
|
+
return code === 'EAGAIN' || code === 'EWOULDBLOCK';
|
|
486
|
+
}
|
|
487
|
+
function hasLiveDeviceInfo(conn) {
|
|
488
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
489
|
+
if (isCDC) {
|
|
490
|
+
// CDC ports may have no read traffic but can still run dashboard/sessions if cached board type exists
|
|
491
|
+
return Boolean(conn.deviceInfo?.board);
|
|
492
|
+
}
|
|
493
|
+
return Boolean(conn.deviceInfo?.board) && conn.lastReadAt > 0;
|
|
494
|
+
}
|
|
113
495
|
/** @internal Exported for testing only */
|
|
114
496
|
export function handleSerialLine(conn, line) {
|
|
115
497
|
if (!line.startsWith('{'))
|
|
@@ -117,15 +499,46 @@ export function handleSerialLine(conn, line) {
|
|
|
117
499
|
try {
|
|
118
500
|
const msg = JSON.parse(line);
|
|
119
501
|
if (msg.type) {
|
|
502
|
+
conn.lastReadAt = Date.now(); // Update last read timestamp
|
|
120
503
|
debug('ESP32', `← ${conn.port}: ${msg.type}`);
|
|
121
|
-
conn.deviceInfo = conn.deviceInfo || {};
|
|
122
504
|
if (msg.type === 'device_info') {
|
|
123
505
|
conn.deviceInfo = {
|
|
124
506
|
board: msg.board,
|
|
125
507
|
version: msg.version,
|
|
508
|
+
buildHash: msg.buildHash,
|
|
509
|
+
buildEpoch: msg.buildEpoch,
|
|
126
510
|
wifiConfigured: msg.wifiConfigured,
|
|
127
511
|
wifiConnected: msg.wifiConnected,
|
|
512
|
+
wifiRadioParked: msg.wifiRadioParked,
|
|
513
|
+
ip: msg.ip,
|
|
514
|
+
uptimeSec: msg.uptimeSec,
|
|
515
|
+
resetReason: msg.resetReason,
|
|
516
|
+
resetReasonCode: msg.resetReasonCode,
|
|
517
|
+
otaSupported: msg.otaSupported,
|
|
518
|
+
otaSlotCount: msg.otaSlotCount,
|
|
519
|
+
otaSlotSize: msg.otaSlotSize,
|
|
520
|
+
otaFreeSketchSpace: msg.otaFreeSketchSpace,
|
|
521
|
+
otaReason: msg.otaReason,
|
|
522
|
+
// Board-side reality counters (debug aid — surfaced on /devices so
|
|
523
|
+
// "device shows nothing" can be diagnosed without stealing the port)
|
|
524
|
+
timelineCount: msg.timelineCount,
|
|
525
|
+
sessionCount: msg.sessionCount,
|
|
526
|
+
usageFiveH: msg.usageFiveH,
|
|
527
|
+
processingCount: msg.processingCount,
|
|
128
528
|
};
|
|
529
|
+
conn.deviceInfoFresh = true;
|
|
530
|
+
if (conn.deviceInfo.board) {
|
|
531
|
+
lastKnownDeviceInfoByPort.set(conn.port, conn.deviceInfo);
|
|
532
|
+
persistDeviceInfoCache();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (!conn.deviceInfoFresh &&
|
|
536
|
+
conn.deviceInfoRequestsSent < DEVICE_INFO_MAX_REQUESTS &&
|
|
537
|
+
Date.now() - conn.lastDeviceInfoRequestAt >= DEVICE_INFO_READ_RETRY_MS) {
|
|
538
|
+
// Retry until a device_info arrives on THIS connection — the cache
|
|
539
|
+
// seed keeps /devices populated meanwhile but must not silence the
|
|
540
|
+
// re-identify (a lost reply or reflashed board would stay stale).
|
|
541
|
+
sendDeviceInfoRequest(conn);
|
|
129
542
|
}
|
|
130
543
|
if (messageHandler) {
|
|
131
544
|
messageHandler(conn.port, msg);
|
|
@@ -136,35 +549,16 @@ export function handleSerialLine(conn, line) {
|
|
|
136
549
|
// Not valid JSON — ignore (ESP32 debug output)
|
|
137
550
|
}
|
|
138
551
|
}
|
|
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
|
|
552
|
+
async function startReadLoop(conn) {
|
|
553
|
+
const buf = Buffer.alloc(1024);
|
|
554
|
+
while (conn.connected && conn.fd != null) {
|
|
162
555
|
try {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
556
|
+
const fd = conn.fd;
|
|
557
|
+
if (fd == null)
|
|
558
|
+
break;
|
|
559
|
+
const { bytesRead } = await readFromFd(fd, buf);
|
|
560
|
+
if (bytesRead > 0) {
|
|
561
|
+
const str = buf.toString('utf-8', 0, bytesRead);
|
|
168
562
|
conn.readBuf += str;
|
|
169
563
|
let newlineIdx;
|
|
170
564
|
while ((newlineIdx = conn.readBuf.indexOf('\n')) !== -1) {
|
|
@@ -174,55 +568,67 @@ async function openPort(port) {
|
|
|
174
568
|
handleSerialLine(conn, line);
|
|
175
569
|
}
|
|
176
570
|
}
|
|
177
|
-
// Prevent buffer bloat from non-newline data
|
|
178
571
|
if (conn.readBuf.length > 8192) {
|
|
179
572
|
conn.readBuf = '';
|
|
180
573
|
}
|
|
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
|
-
});
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// EOF
|
|
577
|
+
await new Promise(r => setTimeout(r, 20));
|
|
578
|
+
}
|
|
198
579
|
}
|
|
199
580
|
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`);
|
|
581
|
+
if (err.code === 'EAGAIN' || err.code === 'EWOULDBLOCK') {
|
|
582
|
+
// No data right now, wait 20ms and try again
|
|
583
|
+
await new Promise(r => setTimeout(r, 20));
|
|
212
584
|
}
|
|
213
|
-
else
|
|
214
|
-
|
|
585
|
+
else {
|
|
586
|
+
debug('ESP32', `Serial read error on ${conn.port}: ${err.message}`);
|
|
587
|
+
closeConnection(conn);
|
|
588
|
+
break;
|
|
215
589
|
}
|
|
216
590
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function openPort(port) {
|
|
594
|
+
let fd = null;
|
|
595
|
+
try {
|
|
596
|
+
// Use one read/write fd. Opening /dev/cu.* separately for read and write
|
|
597
|
+
// can make macOS serial look writable while ESP32 never receives host JSON.
|
|
598
|
+
fd = await openFdWithProbe(port);
|
|
599
|
+
const platform = process.platform;
|
|
600
|
+
const isCDC = /usbmodem|ttyACM/.test(port);
|
|
601
|
+
if (!isCDC && platform === 'darwin') {
|
|
602
|
+
await execWithKill(`stty -f ${port} 115200 cs8 -cstopb -parenb -hupcl raw -echo`);
|
|
603
|
+
}
|
|
604
|
+
else if (!isCDC && platform === 'linux') {
|
|
605
|
+
await execWithKill(`stty -F ${port} 115200 cs8 -cstopb -parenb -hupcl raw -echo`);
|
|
221
606
|
}
|
|
607
|
+
const stream = {
|
|
608
|
+
destroyed: false,
|
|
609
|
+
writable: true,
|
|
610
|
+
write: () => true,
|
|
611
|
+
destroy: () => { },
|
|
612
|
+
on: () => { },
|
|
613
|
+
};
|
|
614
|
+
const conn = {
|
|
615
|
+
port, fd, stream, reader: null, readBuf: '',
|
|
616
|
+
connected: true, deviceInfo: lastKnownDeviceInfoByPort.get(port) ?? null,
|
|
617
|
+
deviceInfoFresh: false, provisionSent: false,
|
|
618
|
+
connectedAt: Date.now(),
|
|
619
|
+
lastReadAt: 0,
|
|
620
|
+
lastWriteAt: Date.now(),
|
|
621
|
+
lastDeviceInfoRequestAt: 0,
|
|
622
|
+
deviceInfoRequestsSent: 0,
|
|
623
|
+
writeQueue: [],
|
|
624
|
+
writeTimer: null,
|
|
625
|
+
};
|
|
222
626
|
const portType = isCDC ? 'CDC' : 'UART';
|
|
223
627
|
debug('ESP32', `Opened serial port (r/w): ${port} [${portType}]`);
|
|
628
|
+
// Start async read loop
|
|
629
|
+
void startReadLoop(conn);
|
|
224
630
|
// Request device info on connect
|
|
225
|
-
|
|
631
|
+
sendDeviceInfoRequest(conn);
|
|
226
632
|
// Send full initial state (state + usage + sessions) so ESP32 doesn't
|
|
227
633
|
// have to wait for next state change or heartbeat cycle
|
|
228
634
|
if (initialStateProvider) {
|
|
@@ -233,34 +639,96 @@ async function openPort(port) {
|
|
|
233
639
|
// Skip usage_update without API data — would reset ESP32 to "no data"
|
|
234
640
|
if (event.type === 'usage_update' && event.fiveHourPercent == null)
|
|
235
641
|
continue;
|
|
236
|
-
|
|
642
|
+
if ((event.type === 'usage_update' || event.type === 'sessions_list') && !hasLiveDeviceInfo(conn))
|
|
643
|
+
continue;
|
|
644
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
237
645
|
}
|
|
238
646
|
}
|
|
239
647
|
else if (stateProvider) {
|
|
240
648
|
const event = stateProvider();
|
|
241
649
|
if (event)
|
|
242
|
-
sendToConnection(conn, JSON.stringify(event));
|
|
650
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
243
651
|
}
|
|
244
652
|
return conn;
|
|
245
653
|
}
|
|
246
654
|
catch (err) {
|
|
247
|
-
|
|
655
|
+
if (fd != null) {
|
|
656
|
+
closeFd(fd);
|
|
657
|
+
}
|
|
658
|
+
serialError(port, `Failed to open ${port}: ${err.message}`);
|
|
248
659
|
return null;
|
|
249
660
|
}
|
|
250
661
|
}
|
|
251
|
-
function
|
|
662
|
+
function closeConnection(conn) {
|
|
663
|
+
conn.connected = false;
|
|
664
|
+
if (conn.writeTimer) {
|
|
665
|
+
clearTimeout(conn.writeTimer);
|
|
666
|
+
conn.writeTimer = null;
|
|
667
|
+
}
|
|
668
|
+
conn.writeQueue = [];
|
|
669
|
+
if (conn.fd != null)
|
|
670
|
+
closeFd(conn.fd);
|
|
671
|
+
conn.fd = null;
|
|
672
|
+
}
|
|
673
|
+
function sendToConnection(conn, json, priority = false) {
|
|
252
674
|
if (!conn.connected)
|
|
253
675
|
return;
|
|
254
|
-
if (
|
|
255
|
-
conn.
|
|
256
|
-
return;
|
|
676
|
+
if (priority) {
|
|
677
|
+
conn.writeQueue.unshift(json);
|
|
257
678
|
}
|
|
258
|
-
|
|
259
|
-
conn.
|
|
679
|
+
else {
|
|
680
|
+
conn.writeQueue.push(json);
|
|
260
681
|
}
|
|
261
|
-
|
|
262
|
-
conn.
|
|
682
|
+
while (conn.writeQueue.length > SERIAL_MAX_QUEUE) {
|
|
683
|
+
const dropIdx = conn.writeQueue.findIndex((queued) => queued.startsWith('{"type":"state_update"') || queued.startsWith('{"type":"sessions_list"'));
|
|
684
|
+
conn.writeQueue.splice(dropIdx >= 0 ? dropIdx : 0, 1);
|
|
263
685
|
}
|
|
686
|
+
scheduleWriteFlush(conn, 0);
|
|
687
|
+
}
|
|
688
|
+
function scheduleWriteFlush(conn, delayMs) {
|
|
689
|
+
if (conn.writeTimer || !conn.connected)
|
|
690
|
+
return;
|
|
691
|
+
conn.writeTimer = setTimeout(() => flushNextWrite(conn), delayMs);
|
|
692
|
+
}
|
|
693
|
+
function flushNextWrite(conn) {
|
|
694
|
+
conn.writeTimer = null;
|
|
695
|
+
if (!conn.connected)
|
|
696
|
+
return;
|
|
697
|
+
const json = conn.writeQueue.shift();
|
|
698
|
+
if (!json)
|
|
699
|
+
return;
|
|
700
|
+
if (conn.fd == null) {
|
|
701
|
+
closeConnection(conn);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
writeToFd(conn.fd, json + '\n')
|
|
705
|
+
.then(() => {
|
|
706
|
+
conn.lastWriteAt = Date.now();
|
|
707
|
+
if (conn.writeQueue.length > 0) {
|
|
708
|
+
scheduleWriteFlush(conn, SERIAL_WRITE_INTERVAL_MS);
|
|
709
|
+
}
|
|
710
|
+
})
|
|
711
|
+
.catch((err) => {
|
|
712
|
+
if (isRetryableSerialIoError(err)) {
|
|
713
|
+
conn.writeQueue.unshift(json);
|
|
714
|
+
scheduleWriteFlush(conn, SERIAL_WRITE_INTERVAL_MS);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
debug('ESP32', `Serial write error on ${conn.port}: ${err.message}`);
|
|
718
|
+
closeConnection(conn);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function sendDeviceInfoRequest(conn) {
|
|
722
|
+
conn.lastDeviceInfoRequestAt = Date.now();
|
|
723
|
+
conn.deviceInfoRequestsSent++;
|
|
724
|
+
sendToConnection(conn, JSON.stringify({ type: 'device_info_request' }), true);
|
|
725
|
+
}
|
|
726
|
+
function sendSerialKeepalive(conn) {
|
|
727
|
+
// ESP32 marks USB as connected only after successfully parsing recent JSON.
|
|
728
|
+
// Keep this independent from larger state/session payloads so display
|
|
729
|
+
// reconnect overlays do not flap when a board is slow to parse UI updates.
|
|
730
|
+
conn.writeQueue = conn.writeQueue.filter((queued) => queued !== SERIAL_KEEPALIVE_JSON);
|
|
731
|
+
sendToConnection(conn, SERIAL_KEEPALIVE_JSON, true);
|
|
264
732
|
}
|
|
265
733
|
/**
|
|
266
734
|
* Register a callback that returns the current state_update event.
|
|
@@ -277,6 +745,25 @@ export function setESP32StateProvider(provider) {
|
|
|
277
745
|
export function setESP32UsageProvider(provider) {
|
|
278
746
|
usageProvider = provider;
|
|
279
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* Register a callback that returns the current display_state event.
|
|
750
|
+
* display_state is otherwise edge-triggered (on change + on connect); a board
|
|
751
|
+
* that misses the wake edge (half-open serial, daemon handoff) would stay
|
|
752
|
+
* dark until power-cycled. The heartbeat re-sync makes it self-heal.
|
|
753
|
+
*/
|
|
754
|
+
export function setESP32DisplayStateProvider(provider) {
|
|
755
|
+
displayStateProvider = provider;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Register a callback that returns the current sessions_list event.
|
|
759
|
+
* Like display_state it is otherwise edge-triggered (on change + on connect);
|
|
760
|
+
* a board that (re)connects during a quiet window — daemon handoff, half-open
|
|
761
|
+
* serial — would sit on an empty roster ("no active sessions") until the next
|
|
762
|
+
* unrelated session change. The heartbeat re-sync makes it self-heal.
|
|
763
|
+
*/
|
|
764
|
+
export function setESP32SessionsListProvider(provider) {
|
|
765
|
+
sessionsListProvider = provider;
|
|
766
|
+
}
|
|
280
767
|
/**
|
|
281
768
|
* Register a provider that returns all initial state events (state + usage + sessions).
|
|
282
769
|
* Called when a new ESP32 device connects to send full state immediately.
|
|
@@ -296,28 +783,222 @@ function sendHeartbeat() {
|
|
|
296
783
|
if (connections.length === 0)
|
|
297
784
|
return;
|
|
298
785
|
heartbeatCount++;
|
|
786
|
+
for (const conn of connections) {
|
|
787
|
+
if (conn.connected)
|
|
788
|
+
sendSerialKeepalive(conn);
|
|
789
|
+
// Refresh the identity snapshot about once a minute even after a fresh
|
|
790
|
+
// device_info landed — its debug counters (sessionCount/timelineCount)
|
|
791
|
+
// are point-in-time, and a connect-moment snapshot reading "0" on
|
|
792
|
+
// /devices masquerades as "board parses nothing".
|
|
793
|
+
if (conn.deviceInfoFresh && Date.now() - conn.lastDeviceInfoRequestAt > 60_000) {
|
|
794
|
+
sendDeviceInfoRequest(conn);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
299
797
|
// Send state_update (stripped for serial — smaller payload)
|
|
300
798
|
if (stateProvider) {
|
|
301
799
|
const event = stateProvider();
|
|
302
800
|
if (event) {
|
|
303
|
-
const json = JSON.stringify(prepareForSerial(event));
|
|
304
801
|
for (const conn of connections) {
|
|
305
|
-
sendToConnection(conn,
|
|
802
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Re-sync display_state every cycle. It is edge-triggered elsewhere, and a
|
|
807
|
+
// board that misses a wake edge would otherwise stay blacked out until the
|
|
808
|
+
// next reconnect or a power cycle (payload is ~70 bytes; the firmware
|
|
809
|
+
// handler is idempotent).
|
|
810
|
+
if (displayStateProvider) {
|
|
811
|
+
const event = displayStateProvider();
|
|
812
|
+
if (event) {
|
|
813
|
+
for (const conn of connections) {
|
|
814
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// Re-sync sessions_list every cycle for the same reason: it is edge-triggered
|
|
819
|
+
// elsewhere, so a board that reconnects during a quiet window shows "no active
|
|
820
|
+
// sessions" until the next session change. Only send once a device has
|
|
821
|
+
// identified (matches the sessions_list gating in the broadcast path).
|
|
822
|
+
if (sessionsListProvider) {
|
|
823
|
+
const event = sessionsListProvider();
|
|
824
|
+
if (event) {
|
|
825
|
+
for (const conn of connections) {
|
|
826
|
+
if (!hasLiveDeviceInfo(conn))
|
|
827
|
+
continue;
|
|
828
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
306
829
|
}
|
|
307
830
|
}
|
|
308
831
|
}
|
|
309
832
|
// Send usage_update (so ESP32 always has fresh usage/reset times)
|
|
310
|
-
// Only send
|
|
311
|
-
// otherwise the ESP32 would reset its cached values
|
|
833
|
+
// Only send once SOME usage signal is present — Claude 5h/7d, Codex limits,
|
|
834
|
+
// or Antigravity credits — otherwise the ESP32 would reset its cached values
|
|
835
|
+
// to the "no data" sentinel before any provider has populated them.
|
|
312
836
|
if (usageProvider) {
|
|
313
837
|
const event = usageProvider();
|
|
314
|
-
|
|
315
|
-
|
|
838
|
+
const u = event;
|
|
839
|
+
const hasUsage = u && (u.fiveHourPercent != null || u.codexRateLimits != null || u.antigravityStatus != null);
|
|
840
|
+
if (hasUsage && event) {
|
|
316
841
|
for (const conn of connections) {
|
|
317
|
-
|
|
842
|
+
if (!hasLiveDeviceInfo(conn))
|
|
843
|
+
continue;
|
|
844
|
+
sendToConnection(conn, JSON.stringify(prepareForSerial(event, conn)));
|
|
318
845
|
}
|
|
319
846
|
}
|
|
320
847
|
}
|
|
848
|
+
// Retry device identification. Some boards answer heartbeats/provisioning
|
|
849
|
+
// but miss the first request after USB reset; without a retry they stay
|
|
850
|
+
// anonymous and surfaces render them as reconnecting.
|
|
851
|
+
//
|
|
852
|
+
// Gate on deviceInfoFresh, NOT conn.deviceInfo: a cache-seeded connection has
|
|
853
|
+
// a non-null (stale) deviceInfo but deviceInfoFresh=false. Keying off
|
|
854
|
+
// conn.deviceInfo froze such a connection at the cache seed forever — a board
|
|
855
|
+
// reflashed/OTA-updated on the same port (e.g. inkdeck round8→round9) would
|
|
856
|
+
// keep reporting its old buildHash/no-OTA because no path ever re-requested.
|
|
857
|
+
// deviceInfoFresh only flips true once a LIVE device_info lands on THIS
|
|
858
|
+
// connection, so a fresh board stops the retries (capped at MAX) as before.
|
|
859
|
+
const now = Date.now();
|
|
860
|
+
for (const conn of connections) {
|
|
861
|
+
if (!shouldRetryDeviceInfoIdentify(conn, now))
|
|
862
|
+
continue;
|
|
863
|
+
const seeded = conn.deviceInfo ? ' (cache-seeded — refreshing identity)' : '';
|
|
864
|
+
debug('ESP32', `${conn.port}: no live device_info yet${seeded} — retrying request`);
|
|
865
|
+
sendDeviceInfoRequest(conn);
|
|
866
|
+
}
|
|
867
|
+
// Check for stale connections and trigger recovery
|
|
868
|
+
checkStaleConnections();
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* A connection that opened cleanly but has never spoken the AgentDeck protocol:
|
|
872
|
+
* no device_info, no cached board for this port, and not a single valid JSON
|
|
873
|
+
* line read. Strong evidence it's a foreign device (e.g. a TRMNL panel) we
|
|
874
|
+
* should stop holding/resetting rather than an AgentDeck board that's just slow.
|
|
875
|
+
*/
|
|
876
|
+
/** @internal Exported for testing only */
|
|
877
|
+
export function isUnidentifiedForeign(conn) {
|
|
878
|
+
return !conn.deviceInfo?.board && !lastKnownDeviceInfoByPort.has(conn.port) && conn.lastReadAt === 0;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* A CDC port that IS an AgentDeck board (live device_info, or a cached board for
|
|
882
|
+
* this port) yet has produced no read since this (re)connection. A healthy board
|
|
883
|
+
* answers device_info/keepalive within seconds, so lastReadAt===0 well past a
|
|
884
|
+
* generous grace means a half-open USB-CDC pipe (board reset / re-enumerated)
|
|
885
|
+
* that heartbeat writes will never revive — the FD must be recycled so re-poll
|
|
886
|
+
* re-probes. Mutually exclusive with isUnidentifiedForeign (this REQUIRES an
|
|
887
|
+
* identified board; that one requires none).
|
|
888
|
+
*/
|
|
889
|
+
/** @internal Exported for testing only */
|
|
890
|
+
export function isHalfOpenIdentifiedCdc(conn, now = Date.now()) {
|
|
891
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
892
|
+
if (!isCDC)
|
|
893
|
+
return false;
|
|
894
|
+
const isIdentified = Boolean(conn.deviceInfo?.board) || lastKnownDeviceInfoByPort.has(conn.port);
|
|
895
|
+
return isIdentified && conn.lastReadAt === 0 && (now - conn.connectedAt) > CDC_SILENT_READ_TIMEOUT_MS;
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Whether the periodic heartbeat should (re)send a device_info_request to
|
|
899
|
+
* re-identify this connection. Keys off deviceInfoFresh (a LIVE reply on THIS
|
|
900
|
+
* connection), NOT conn.deviceInfo — a cache-seeded connection carries a stale
|
|
901
|
+
* deviceInfo but deviceInfoFresh=false and must keep re-requesting until the
|
|
902
|
+
* board answers, so a reflashed/OTA-updated board on the same port refreshes
|
|
903
|
+
* its buildHash/OTA fields instead of freezing at the cache seed. Capped by
|
|
904
|
+
* DEVICE_INFO_MAX_REQUESTS and paced by DEVICE_INFO_RETRY_MS.
|
|
905
|
+
*/
|
|
906
|
+
/** @internal Exported for testing only */
|
|
907
|
+
export function shouldRetryDeviceInfoIdentify(conn, now = Date.now()) {
|
|
908
|
+
if (!conn.connected || conn.deviceInfoFresh)
|
|
909
|
+
return false;
|
|
910
|
+
if (conn.deviceInfoRequestsSent >= DEVICE_INFO_MAX_REQUESTS)
|
|
911
|
+
return false;
|
|
912
|
+
if (now - conn.lastDeviceInfoRequestAt < DEVICE_INFO_RETRY_MS)
|
|
913
|
+
return false;
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
function denylistForeignPort(port, reason) {
|
|
917
|
+
foreignDenylistUntil.set(port, Date.now() + FOREIGN_DENYLIST_COOLDOWN_MS);
|
|
918
|
+
foreignProbeFailures.delete(port);
|
|
919
|
+
debug('ESP32', `Denylisting non-AgentDeck port ${port} for ${Math.round(FOREIGN_DENYLIST_COOLDOWN_MS / 60000)}min (${reason})`);
|
|
920
|
+
}
|
|
921
|
+
/** @internal Exported for testing only. True if the port is in active cooldown. */
|
|
922
|
+
export function isForeignPortDenylisted(port, now = Date.now()) {
|
|
923
|
+
const until = foreignDenylistUntil.get(port);
|
|
924
|
+
return until != null && until > now;
|
|
925
|
+
}
|
|
926
|
+
/** @internal Exported for testing only. Clear all foreign-port tracking state. */
|
|
927
|
+
export function __resetForeignPortState() {
|
|
928
|
+
foreignProbeFailures.clear();
|
|
929
|
+
foreignDenylistUntil.clear();
|
|
930
|
+
}
|
|
931
|
+
/** @internal Exported for testing only.
|
|
932
|
+
* Count a failed identification cycle; denylist once it crosses the threshold. */
|
|
933
|
+
export function recordForeignProbeFailure(port) {
|
|
934
|
+
const next = (foreignProbeFailures.get(port) ?? 0) + 1;
|
|
935
|
+
foreignProbeFailures.set(port, next);
|
|
936
|
+
if (next >= FOREIGN_MAX_PROBE_FAILURES) {
|
|
937
|
+
denylistForeignPort(port, `${next} failed probes`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Check for stale connections (no read for >60s) and close them.
|
|
942
|
+
* This allows re-poll to automatically reconnect devices that stopped responding.
|
|
943
|
+
*/
|
|
944
|
+
function checkStaleConnections() {
|
|
945
|
+
const now = Date.now();
|
|
946
|
+
let staleCount = 0;
|
|
947
|
+
for (const conn of connections) {
|
|
948
|
+
if (!conn.connected)
|
|
949
|
+
continue;
|
|
950
|
+
const isCDC = /usbmodem|ttyACM/.test(conn.port);
|
|
951
|
+
const hasRead = conn.lastReadAt > 0;
|
|
952
|
+
const readAge = hasRead ? now - conn.lastReadAt : now - conn.connectedAt;
|
|
953
|
+
const writeAge = now - conn.lastWriteAt;
|
|
954
|
+
// For CDC ports, do not close due to lack of read traffic if write is active and healthy.
|
|
955
|
+
if (isCDC) {
|
|
956
|
+
// A held-open CDC port that never identifies is a foreign device; denylist
|
|
957
|
+
// it so we stop occupying it (CDC ports aren't otherwise reaped on silence).
|
|
958
|
+
if (isUnidentifiedForeign(conn) && (now - conn.connectedAt) > FOREIGN_CDC_GRACE_MS) {
|
|
959
|
+
denylistForeignPort(conn.port, 'CDC never identified');
|
|
960
|
+
closeConnection(conn);
|
|
961
|
+
staleCount++;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
// A CDC port whose board is identified but has read nothing since connect is
|
|
965
|
+
// half-open (board reset, RX pipe dead). Recycle the FD so re-poll re-probes;
|
|
966
|
+
// the reopen's DTR/RTS toggle may itself revive the board. Feed the foreign
|
|
967
|
+
// probe-failure counter so a permanently-dead board denylists after a few
|
|
968
|
+
// strikes instead of getting DTR/RTS-reset every grace window.
|
|
969
|
+
if (isHalfOpenIdentifiedCdc(conn, now)) {
|
|
970
|
+
debug('ESP32', `Half-open CDC (identified, no read since connect): ${conn.port} — recycling`);
|
|
971
|
+
recordForeignProbeFailure(conn.port);
|
|
972
|
+
closeConnection(conn);
|
|
973
|
+
staleCount++;
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
if (writeAge > STALE_THRESHOLD_MS) {
|
|
977
|
+
debug('ESP32', `Stale CDC connection (no write for >60s): ${conn.port}`);
|
|
978
|
+
closeConnection(conn);
|
|
979
|
+
staleCount++;
|
|
980
|
+
}
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if ((!hasRead && readAge > INITIAL_READ_TIMEOUT_MS) ||
|
|
984
|
+
(hasRead && readAge > STALE_THRESHOLD_MS && writeAge > STALE_THRESHOLD_MS)) {
|
|
985
|
+
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)`);
|
|
986
|
+
// A UART port closed before ever identifying counts as a foreign probe
|
|
987
|
+
// failure; enough failures denylist it so we stop reopening (and resetting) it.
|
|
988
|
+
if (isUnidentifiedForeign(conn))
|
|
989
|
+
recordForeignProbeFailure(conn.port);
|
|
990
|
+
closeConnection(conn);
|
|
991
|
+
staleCount++;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (staleCount > 0) {
|
|
995
|
+
// Remove stale connections immediately, then trigger re-poll
|
|
996
|
+
connections = connections.filter(c => c.connected);
|
|
997
|
+
debug('ESP32', `Closed ${staleCount} stale connection(s), re-polling...`);
|
|
998
|
+
pollForDevices().catch(err => {
|
|
999
|
+
debug('ESP32', `Re-poll after stale cleanup failed: ${err.message}`);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
321
1002
|
}
|
|
322
1003
|
/**
|
|
323
1004
|
* Start ESP32 serial bridge.
|
|
@@ -328,6 +1009,7 @@ function sendHeartbeat() {
|
|
|
328
1009
|
* USB port (stty stuck in kernel I/O) cannot block bridge startup.
|
|
329
1010
|
*/
|
|
330
1011
|
export function startESP32Serial() {
|
|
1012
|
+
loadDeviceInfoCache();
|
|
331
1013
|
// Fire-and-forget initial detection (non-blocking)
|
|
332
1014
|
pollForDevices().catch(err => {
|
|
333
1015
|
debug('ESP32', `Initial poll failed: ${err.message}`);
|
|
@@ -343,31 +1025,69 @@ export function startESP32Serial() {
|
|
|
343
1025
|
debug('ESP32', 'Serial bridge started');
|
|
344
1026
|
}
|
|
345
1027
|
async function pollForDevices() {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
1028
|
+
if (pollInProgress)
|
|
1029
|
+
return;
|
|
1030
|
+
pollInProgress = true;
|
|
1031
|
+
try {
|
|
1032
|
+
const ports = [...new Set(await detectESP32Ports())];
|
|
1033
|
+
const portSet = new Set(ports);
|
|
1034
|
+
// Forget foreign-port state for ports that are gone (a replug re-probes
|
|
1035
|
+
// fresh) or whose cooldown has elapsed. Then skip ports still in cooldown so
|
|
1036
|
+
// we don't reopen — and reset — a non-AgentDeck device every poll.
|
|
1037
|
+
const nowTs = Date.now();
|
|
1038
|
+
for (const [p, until] of [...foreignDenylistUntil]) {
|
|
1039
|
+
if (until <= nowTs || !portSet.has(p))
|
|
1040
|
+
foreignDenylistUntil.delete(p);
|
|
1041
|
+
}
|
|
1042
|
+
for (const p of [...foreignProbeFailures.keys()]) {
|
|
1043
|
+
if (!portSet.has(p))
|
|
1044
|
+
foreignProbeFailures.delete(p);
|
|
1045
|
+
}
|
|
1046
|
+
const allowedPorts = ports.filter((p) => {
|
|
1047
|
+
const until = foreignDenylistUntil.get(p);
|
|
1048
|
+
if (until && until > nowTs) {
|
|
1049
|
+
debug('ESP32', `Skipping denylisted non-AgentDeck port ${p} (${Math.ceil((until - nowTs) / 1000)}s left)`);
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
return true;
|
|
1053
|
+
});
|
|
1054
|
+
// Remove disconnected or unplugged ports. Read silence alone is handled
|
|
1055
|
+
// separately so half-duplex-but-rendering boards are not reset every minute.
|
|
1056
|
+
connections = connections.filter(c => {
|
|
1057
|
+
if (!c.connected) {
|
|
1058
|
+
closeConnection(c);
|
|
1059
|
+
return false;
|
|
352
1060
|
}
|
|
353
|
-
|
|
1061
|
+
if (!portSet.has(c.port)) {
|
|
1062
|
+
debug('ESP32', `Serial port disappeared: ${c.port}`);
|
|
1063
|
+
closeConnection(c);
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
return true;
|
|
1067
|
+
});
|
|
1068
|
+
// Add new ports (denylisted foreign ports are excluded via allowedPorts)
|
|
1069
|
+
for (const port of allowedPorts) {
|
|
1070
|
+
if (connections.some(c => c.port === port) || openingPorts.has(port))
|
|
1071
|
+
continue;
|
|
1072
|
+
openingPorts.add(port);
|
|
354
1073
|
try {
|
|
355
|
-
|
|
1074
|
+
const conn = await openPort(port);
|
|
1075
|
+
if (conn && !connections.some(c => c.port === port)) {
|
|
1076
|
+
connections.push(conn);
|
|
1077
|
+
}
|
|
1078
|
+
else if (conn) {
|
|
1079
|
+
conn.connected = false;
|
|
1080
|
+
closeConnection(conn);
|
|
1081
|
+
}
|
|
356
1082
|
}
|
|
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);
|
|
1083
|
+
finally {
|
|
1084
|
+
openingPorts.delete(port);
|
|
368
1085
|
}
|
|
369
1086
|
}
|
|
370
1087
|
}
|
|
1088
|
+
finally {
|
|
1089
|
+
pollInProgress = false;
|
|
1090
|
+
}
|
|
371
1091
|
}
|
|
372
1092
|
/**
|
|
373
1093
|
* Broadcast a BridgeEvent to all connected ESP32 devices via serial.
|
|
@@ -377,7 +1097,6 @@ export function broadcastESP32(event) {
|
|
|
377
1097
|
return;
|
|
378
1098
|
if (!FORWARDED_EVENTS.has(event.type))
|
|
379
1099
|
return;
|
|
380
|
-
const prepared = prepareForSerial(event);
|
|
381
1100
|
// Debug: log sessions_list and state_update details for ESP32 troubleshooting
|
|
382
1101
|
if (event.type === 'sessions_list') {
|
|
383
1102
|
const e = event;
|
|
@@ -388,9 +1107,11 @@ export function broadcastESP32(event) {
|
|
|
388
1107
|
const e = event;
|
|
389
1108
|
debug('ESP32', `→ state_update: agent=${e.agentType} state=${e.state}`);
|
|
390
1109
|
}
|
|
391
|
-
const json = JSON.stringify(prepared);
|
|
392
1110
|
for (const conn of connections) {
|
|
393
|
-
|
|
1111
|
+
if (event.type === 'sessions_list' && !hasLiveDeviceInfo(conn))
|
|
1112
|
+
continue;
|
|
1113
|
+
const prepared = prepareForSerial(event, conn);
|
|
1114
|
+
sendToConnection(conn, JSON.stringify(prepared));
|
|
394
1115
|
}
|
|
395
1116
|
}
|
|
396
1117
|
/**
|
|
@@ -400,7 +1121,7 @@ export function sendWifiProvision(port, msg) {
|
|
|
400
1121
|
const conn = connections.find(c => c.port === port && c.connected);
|
|
401
1122
|
if (!conn)
|
|
402
1123
|
return false;
|
|
403
|
-
sendToConnection(conn, JSON.stringify(msg));
|
|
1124
|
+
sendToConnection(conn, JSON.stringify(msg), true);
|
|
404
1125
|
conn.provisionSent = true;
|
|
405
1126
|
debug('ESP32', `→ ${port}: wifi_provision (SSID: ${msg.ssid})`);
|
|
406
1127
|
return true;
|
|
@@ -411,31 +1132,76 @@ export function sendWifiProvision(port, msg) {
|
|
|
411
1132
|
export function sendWifiProvisionToAll(msg) {
|
|
412
1133
|
let count = 0;
|
|
413
1134
|
for (const conn of connections) {
|
|
414
|
-
if (!conn
|
|
415
|
-
continue;
|
|
416
|
-
// Skip if already provisioned or WiFi already configured
|
|
417
|
-
if (conn.provisionSent)
|
|
1135
|
+
if (!shouldSendWifiProvision(conn))
|
|
418
1136
|
continue;
|
|
419
|
-
|
|
420
|
-
continue;
|
|
421
|
-
sendToConnection(conn, JSON.stringify(msg));
|
|
1137
|
+
sendToConnection(conn, JSON.stringify(msg), true);
|
|
422
1138
|
conn.provisionSent = true;
|
|
423
1139
|
count++;
|
|
424
1140
|
debug('ESP32', `→ ${conn.port}: wifi_provision (SSID: ${msg.ssid})`);
|
|
425
1141
|
}
|
|
426
1142
|
return count;
|
|
427
1143
|
}
|
|
1144
|
+
/** @internal Exported for testing only */
|
|
1145
|
+
export function shouldSendWifiProvision(conn) {
|
|
1146
|
+
if (!conn.connected)
|
|
1147
|
+
return false;
|
|
1148
|
+
if (!conn.deviceInfo?.board)
|
|
1149
|
+
return false;
|
|
1150
|
+
if (conn.provisionSent)
|
|
1151
|
+
return false;
|
|
1152
|
+
// IPS10 carries a daemon endpoint in NVS in addition to WiFi credentials.
|
|
1153
|
+
// Refresh it once while USB serial is active and the radio is online, even
|
|
1154
|
+
// if the board is already configured/connected; otherwise a stale endpoint
|
|
1155
|
+
// can survive and WiFi-only operation keeps dialing the old daemon host.
|
|
1156
|
+
if (conn.deviceInfo.board === 'ips_10' && conn.deviceInfo.wifiConnected && !conn.deviceInfo.wifiRadioParked) {
|
|
1157
|
+
return true;
|
|
1158
|
+
}
|
|
1159
|
+
if (conn.deviceInfo.wifiConnected)
|
|
1160
|
+
return false;
|
|
1161
|
+
// Auto-provision is for first setup. A configured board may intentionally have
|
|
1162
|
+
// WiFi off (IPS10 USB-primary radio parking) or may be temporarily away from
|
|
1163
|
+
// its AP; repeatedly injecting credentials would fight firmware policy.
|
|
1164
|
+
if (conn.deviceInfo.wifiConfigured)
|
|
1165
|
+
return false;
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
428
1168
|
/**
|
|
429
1169
|
* Get device info for all connected ESP32 devices.
|
|
430
1170
|
*/
|
|
431
1171
|
export function getESP32DeviceInfo() {
|
|
1172
|
+
const now = Date.now();
|
|
432
1173
|
return connections
|
|
433
|
-
.filter(c => c
|
|
1174
|
+
.filter(c => isResponsive(c, now))
|
|
434
1175
|
.map(c => ({
|
|
435
1176
|
port: c.port,
|
|
436
1177
|
...c.deviceInfo,
|
|
437
1178
|
}));
|
|
438
1179
|
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Board identities currently reachable over a LIVE, responsive USB serial
|
|
1182
|
+
* connection (has announced device_info and is actively read/written). The
|
|
1183
|
+
* daemon uses this to enforce single-path communication: when a physical board
|
|
1184
|
+
* is driven over serial, its WiFi WebSocket copy is redundant and is suppressed
|
|
1185
|
+
* (see daemon-server transport dedup). A power-only USB (no enumerated port) or
|
|
1186
|
+
* a silent/booting port is NOT responsive, so it never appears here and the
|
|
1187
|
+
* board keeps using WiFi — exactly the desired fallback.
|
|
1188
|
+
*
|
|
1189
|
+
* `ip` is the board's own WiFi IP (when connected), used to confirm the serial
|
|
1190
|
+
* connection and the WiFi socket are the same physical unit before deduping.
|
|
1191
|
+
*/
|
|
1192
|
+
export function getSerialReachableBoards() {
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
const out = [];
|
|
1195
|
+
for (const c of connections) {
|
|
1196
|
+
if (!isResponsive(c, now))
|
|
1197
|
+
continue;
|
|
1198
|
+
const board = c.deviceInfo?.board;
|
|
1199
|
+
if (!board)
|
|
1200
|
+
continue;
|
|
1201
|
+
out.push({ board, ip: c.deviceInfo?.wifiConnected ? c.deviceInfo?.ip : undefined });
|
|
1202
|
+
}
|
|
1203
|
+
return out;
|
|
1204
|
+
}
|
|
439
1205
|
/**
|
|
440
1206
|
* Wake recovery — close stale file descriptors, force immediate re-poll.
|
|
441
1207
|
*/
|
|
@@ -443,14 +1209,7 @@ export function handleESP32Wake() {
|
|
|
443
1209
|
debug('ESP32', `Wake recovery — closing ${connections.length} stale connection(s)`);
|
|
444
1210
|
for (const conn of connections) {
|
|
445
1211
|
conn.connected = false;
|
|
446
|
-
|
|
447
|
-
conn.stream.end();
|
|
448
|
-
}
|
|
449
|
-
catch { /* ignore */ }
|
|
450
|
-
try {
|
|
451
|
-
conn.reader?.destroy();
|
|
452
|
-
}
|
|
453
|
-
catch { /* ignore */ }
|
|
1212
|
+
closeConnection(conn);
|
|
454
1213
|
}
|
|
455
1214
|
connections = [];
|
|
456
1215
|
// Delay re-poll 2s to let USB bus stabilize after wake
|
|
@@ -474,14 +1233,7 @@ export function stopESP32Serial() {
|
|
|
474
1233
|
}
|
|
475
1234
|
for (const conn of connections) {
|
|
476
1235
|
conn.connected = false;
|
|
477
|
-
|
|
478
|
-
conn.stream.end();
|
|
479
|
-
}
|
|
480
|
-
catch { /* ignore */ }
|
|
481
|
-
try {
|
|
482
|
-
conn.reader?.destroy();
|
|
483
|
-
}
|
|
484
|
-
catch { /* ignore */ }
|
|
1236
|
+
closeConnection(conn);
|
|
485
1237
|
}
|
|
486
1238
|
connections = [];
|
|
487
1239
|
messageHandler = null;
|
|
@@ -491,12 +1243,57 @@ export function stopESP32Serial() {
|
|
|
491
1243
|
* Get number of connected ESP32 devices.
|
|
492
1244
|
*/
|
|
493
1245
|
export function esp32ConnectionCount() {
|
|
494
|
-
|
|
1246
|
+
const now = Date.now();
|
|
1247
|
+
return connections.filter(c => isResponsive(c, now)).length;
|
|
495
1248
|
}
|
|
496
1249
|
/**
|
|
497
1250
|
* Get list of connected ESP32 serial port paths.
|
|
498
1251
|
*/
|
|
499
1252
|
export function getESP32Ports() {
|
|
500
|
-
|
|
1253
|
+
const now = Date.now();
|
|
1254
|
+
return connections.filter(c => isResponsive(c, now)).map(c => c.port);
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Get detailed connection status for all ESP32 devices.
|
|
1258
|
+
* Used by /api/status endpoint to show connection health.
|
|
1259
|
+
*/
|
|
1260
|
+
export function getSerialConnectionStatus() {
|
|
1261
|
+
const now = Date.now();
|
|
1262
|
+
return connections.map(c => ({
|
|
1263
|
+
port: c.port,
|
|
1264
|
+
connected: isResponsive(c, now),
|
|
1265
|
+
transportOpen: c.connected,
|
|
1266
|
+
board: c.deviceInfo?.board,
|
|
1267
|
+
version: c.deviceInfo?.version,
|
|
1268
|
+
buildHash: c.deviceInfo?.buildHash,
|
|
1269
|
+
buildEpoch: c.deviceInfo?.buildEpoch,
|
|
1270
|
+
wifiConfigured: c.deviceInfo?.wifiConfigured,
|
|
1271
|
+
wifiConnected: c.deviceInfo?.wifiConnected,
|
|
1272
|
+
wifiRadioParked: c.deviceInfo?.wifiRadioParked,
|
|
1273
|
+
uptimeSec: c.deviceInfo?.uptimeSec,
|
|
1274
|
+
resetReason: c.deviceInfo?.resetReason,
|
|
1275
|
+
resetReasonCode: c.deviceInfo?.resetReasonCode,
|
|
1276
|
+
otaSupported: c.deviceInfo?.otaSupported,
|
|
1277
|
+
otaSlotCount: c.deviceInfo?.otaSlotCount,
|
|
1278
|
+
otaSlotSize: c.deviceInfo?.otaSlotSize,
|
|
1279
|
+
otaFreeSketchSpace: c.deviceInfo?.otaFreeSketchSpace,
|
|
1280
|
+
otaReason: c.deviceInfo?.otaReason,
|
|
1281
|
+
timelineCount: c.deviceInfo?.timelineCount,
|
|
1282
|
+
sessionCount: c.deviceInfo?.sessionCount,
|
|
1283
|
+
usageFiveH: c.deviceInfo?.usageFiveH,
|
|
1284
|
+
processingCount: c.deviceInfo?.processingCount,
|
|
1285
|
+
lastReadAt: c.lastReadAt,
|
|
1286
|
+
lastWriteAt: c.lastWriteAt,
|
|
1287
|
+
lastReadSecondsAgo: c.lastReadAt > 0 ? Math.floor((now - c.lastReadAt) / 1000) : null,
|
|
1288
|
+
lastWriteSecondsAgo: Math.floor((now - c.lastWriteAt) / 1000),
|
|
1289
|
+
stale: c.connected && !isResponsive(c, now),
|
|
1290
|
+
}));
|
|
1291
|
+
}
|
|
1292
|
+
export function getSerialLastError() {
|
|
1293
|
+
if (lastErrorByPort.size === 0)
|
|
1294
|
+
return null;
|
|
1295
|
+
return Array.from(lastErrorByPort.entries())
|
|
1296
|
+
.map(([port, error]) => `${port}: ${error}`)
|
|
1297
|
+
.join('; ');
|
|
501
1298
|
}
|
|
502
1299
|
//# sourceMappingURL=esp32-serial.js.map
|