@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.
Files changed (435) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/assets/fonts/IBMPlexSans-Bold.ttf +0 -0
  3. package/assets/fonts/IBMPlexSans-Regular.ttf +0 -0
  4. package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
  5. package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
  6. package/assets/fonts/LICENSES.md +22 -0
  7. package/dist/adapters/claude-code.d.ts +6 -0
  8. package/dist/adapters/claude-code.d.ts.map +1 -1
  9. package/dist/adapters/claude-code.js +8 -0
  10. package/dist/adapters/claude-code.js.map +1 -1
  11. package/dist/adapters/openclaw.d.ts +24 -0
  12. package/dist/adapters/openclaw.d.ts.map +1 -1
  13. package/dist/adapters/openclaw.js +291 -35
  14. package/dist/adapters/openclaw.js.map +1 -1
  15. package/dist/adapters/opencode-adapter.d.ts +59 -0
  16. package/dist/adapters/opencode-adapter.d.ts.map +1 -1
  17. package/dist/adapters/opencode-adapter.js +297 -35
  18. package/dist/adapters/opencode-adapter.js.map +1 -1
  19. package/dist/adb-reverse.d.ts +1 -1
  20. package/dist/adb-reverse.js +1 -1
  21. package/dist/antigravity-local.d.ts +1 -0
  22. package/dist/antigravity-local.d.ts.map +1 -1
  23. package/dist/antigravity-local.js +33 -0
  24. package/dist/antigravity-local.js.map +1 -1
  25. package/dist/apme/adapters/claude-hook.d.ts.map +1 -1
  26. package/dist/apme/adapters/claude-hook.js +5 -0
  27. package/dist/apme/adapters/claude-hook.js.map +1 -1
  28. package/dist/apme/adapters/codex-hook.d.ts +17 -0
  29. package/dist/apme/adapters/codex-hook.d.ts.map +1 -0
  30. package/dist/apme/adapters/codex-hook.js +95 -0
  31. package/dist/apme/adapters/codex-hook.js.map +1 -0
  32. package/dist/apme/adapters/codex-turn-manager.d.ts +86 -0
  33. package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -0
  34. package/dist/apme/adapters/codex-turn-manager.js +496 -0
  35. package/dist/apme/adapters/codex-turn-manager.js.map +1 -0
  36. package/dist/apme/adapters/openclaw-hook.d.ts +99 -0
  37. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -0
  38. package/dist/apme/adapters/openclaw-hook.js +234 -0
  39. package/dist/apme/adapters/openclaw-hook.js.map +1 -0
  40. package/dist/apme/adapters/opencode-hook.d.ts +56 -0
  41. package/dist/apme/adapters/opencode-hook.d.ts.map +1 -0
  42. package/dist/apme/adapters/opencode-hook.js +182 -0
  43. package/dist/apme/adapters/opencode-hook.js.map +1 -0
  44. package/dist/apme/adapters/timeline.d.ts.map +1 -1
  45. package/dist/apme/adapters/timeline.js +6 -1
  46. package/dist/apme/adapters/timeline.js.map +1 -1
  47. package/dist/apme/antigravity-transcript.d.ts +18 -0
  48. package/dist/apme/antigravity-transcript.d.ts.map +1 -0
  49. package/dist/apme/antigravity-transcript.js +94 -0
  50. package/dist/apme/antigravity-transcript.js.map +1 -0
  51. package/dist/apme/classify-turn.d.ts +13 -0
  52. package/dist/apme/classify-turn.d.ts.map +1 -0
  53. package/dist/apme/classify-turn.js +49 -0
  54. package/dist/apme/classify-turn.js.map +1 -0
  55. package/dist/apme/claude-transcript-reader.d.ts +10 -0
  56. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  57. package/dist/apme/claude-transcript-reader.js +35 -0
  58. package/dist/apme/claude-transcript-reader.js.map +1 -1
  59. package/dist/apme/collector.d.ts +133 -6
  60. package/dist/apme/collector.d.ts.map +1 -1
  61. package/dist/apme/collector.js +471 -23
  62. package/dist/apme/collector.js.map +1 -1
  63. package/dist/apme/dashboard-html.d.ts.map +1 -1
  64. package/dist/apme/dashboard-html.js +117 -11
  65. package/dist/apme/dashboard-html.js.map +1 -1
  66. package/dist/apme/http.d.ts.map +1 -1
  67. package/dist/apme/http.js +52 -6
  68. package/dist/apme/http.js.map +1 -1
  69. package/dist/apme/index.d.ts +26 -5
  70. package/dist/apme/index.d.ts.map +1 -1
  71. package/dist/apme/index.js +146 -9
  72. package/dist/apme/index.js.map +1 -1
  73. package/dist/apme/judge-detect.d.ts +26 -0
  74. package/dist/apme/judge-detect.d.ts.map +1 -0
  75. package/dist/apme/judge-detect.js +69 -0
  76. package/dist/apme/judge-detect.js.map +1 -0
  77. package/dist/apme/pareto.d.ts +39 -0
  78. package/dist/apme/pareto.d.ts.map +1 -0
  79. package/dist/apme/pareto.js +59 -0
  80. package/dist/apme/pareto.js.map +1 -0
  81. package/dist/apme/recommend.d.ts +3 -0
  82. package/dist/apme/recommend.d.ts.map +1 -1
  83. package/dist/apme/recommend.js +43 -1
  84. package/dist/apme/recommend.js.map +1 -1
  85. package/dist/apme/runner.d.ts +99 -4
  86. package/dist/apme/runner.d.ts.map +1 -1
  87. package/dist/apme/runner.js +743 -72
  88. package/dist/apme/runner.js.map +1 -1
  89. package/dist/apme/sample-to-timeline.d.ts +26 -0
  90. package/dist/apme/sample-to-timeline.d.ts.map +1 -0
  91. package/dist/apme/sample-to-timeline.js +95 -0
  92. package/dist/apme/sample-to-timeline.js.map +1 -0
  93. package/dist/apme/scorers/index.d.ts +47 -0
  94. package/dist/apme/scorers/index.d.ts.map +1 -0
  95. package/dist/apme/scorers/index.js +96 -0
  96. package/dist/apme/scorers/index.js.map +1 -0
  97. package/dist/apme/settings.d.ts +17 -11
  98. package/dist/apme/settings.d.ts.map +1 -1
  99. package/dist/apme/settings.js +30 -9
  100. package/dist/apme/settings.js.map +1 -1
  101. package/dist/apme/store.d.ts +33 -1
  102. package/dist/apme/store.d.ts.map +1 -1
  103. package/dist/apme/store.js +334 -25
  104. package/dist/apme/store.js.map +1 -1
  105. package/dist/awaiting-overlay.d.ts +112 -0
  106. package/dist/awaiting-overlay.d.ts.map +1 -0
  107. package/dist/awaiting-overlay.js +179 -0
  108. package/dist/awaiting-overlay.js.map +1 -0
  109. package/dist/ble-sync-spawn.d.ts +63 -0
  110. package/dist/ble-sync-spawn.d.ts.map +1 -0
  111. package/dist/ble-sync-spawn.js +196 -0
  112. package/dist/ble-sync-spawn.js.map +1 -0
  113. package/dist/bridge-core.d.ts +34 -1
  114. package/dist/bridge-core.d.ts.map +1 -1
  115. package/dist/bridge-core.js +207 -24
  116. package/dist/bridge-core.js.map +1 -1
  117. package/dist/broadcast.d.ts +3 -0
  118. package/dist/broadcast.d.ts.map +1 -0
  119. package/dist/broadcast.js +122 -0
  120. package/dist/broadcast.js.map +1 -0
  121. package/dist/check-deps.d.ts.map +1 -1
  122. package/dist/check-deps.js +25 -9
  123. package/dist/check-deps.js.map +1 -1
  124. package/dist/claude-permission-rules.d.ts +55 -0
  125. package/dist/claude-permission-rules.d.ts.map +1 -0
  126. package/dist/claude-permission-rules.js +197 -0
  127. package/dist/claude-permission-rules.js.map +1 -0
  128. package/dist/cli.d.ts +15 -1
  129. package/dist/cli.d.ts.map +1 -1
  130. package/dist/cli.js +1030 -93
  131. package/dist/cli.js.map +1 -1
  132. package/dist/codex-auth.d.ts +21 -0
  133. package/dist/codex-auth.d.ts.map +1 -1
  134. package/dist/codex-auth.js +52 -1
  135. package/dist/codex-auth.js.map +1 -1
  136. package/dist/codex-output-parser.d.ts +8 -0
  137. package/dist/codex-output-parser.d.ts.map +1 -1
  138. package/dist/codex-output-parser.js +110 -25
  139. package/dist/codex-output-parser.js.map +1 -1
  140. package/dist/codex-rate-limits.d.ts +16 -0
  141. package/dist/codex-rate-limits.d.ts.map +1 -0
  142. package/dist/codex-rate-limits.js +197 -0
  143. package/dist/codex-rate-limits.js.map +1 -0
  144. package/dist/codex-rollout-response.d.ts +31 -0
  145. package/dist/codex-rollout-response.d.ts.map +1 -0
  146. package/dist/codex-rollout-response.js +135 -0
  147. package/dist/codex-rollout-response.js.map +1 -0
  148. package/dist/d200h/image-renderer.d.ts +4 -16
  149. package/dist/d200h/image-renderer.d.ts.map +1 -1
  150. package/dist/d200h/image-renderer.js +46 -212
  151. package/dist/d200h/image-renderer.js.map +1 -1
  152. package/dist/daemon-server.d.ts +85 -0
  153. package/dist/daemon-server.d.ts.map +1 -1
  154. package/dist/daemon-server.js +1975 -159
  155. package/dist/daemon-server.js.map +1 -1
  156. package/dist/daemon.js +15 -6
  157. package/dist/daemon.js.map +1 -1
  158. package/dist/display-dim.d.ts +9 -0
  159. package/dist/display-dim.d.ts.map +1 -0
  160. package/dist/display-dim.js +53 -0
  161. package/dist/display-dim.js.map +1 -0
  162. package/dist/display-monitor.d.ts +15 -2
  163. package/dist/display-monitor.d.ts.map +1 -1
  164. package/dist/display-monitor.js +104 -26
  165. package/dist/display-monitor.js.map +1 -1
  166. package/dist/esp32-serial.d.ts +190 -1
  167. package/dist/esp32-serial.d.ts.map +1 -1
  168. package/dist/esp32-serial.js +1090 -156
  169. package/dist/esp32-serial.js.map +1 -1
  170. package/dist/fallback-task-timeline.d.ts +42 -0
  171. package/dist/fallback-task-timeline.d.ts.map +1 -0
  172. package/dist/fallback-task-timeline.js +119 -0
  173. package/dist/fallback-task-timeline.js.map +1 -0
  174. package/dist/foundation-models-helper.d.ts +11 -0
  175. package/dist/foundation-models-helper.d.ts.map +1 -0
  176. package/dist/foundation-models-helper.js +241 -0
  177. package/dist/foundation-models-helper.js.map +1 -0
  178. package/dist/hook-migration.js +1 -1
  179. package/dist/hook-migration.js.map +1 -1
  180. package/dist/hook-server.d.ts +2 -3
  181. package/dist/hook-server.d.ts.map +1 -1
  182. package/dist/hook-server.js +23 -10
  183. package/dist/hook-server.js.map +1 -1
  184. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts +33 -0
  185. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -0
  186. package/dist/idotmatrix/idotmatrix-daemon-sync.js +164 -0
  187. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -0
  188. package/dist/idotmatrix/idotmatrix-discover.d.ts +18 -0
  189. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -0
  190. package/dist/idotmatrix/idotmatrix-discover.js +85 -0
  191. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -0
  192. package/dist/idotmatrix/idotmatrix-settings.d.ts +20 -0
  193. package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -0
  194. package/dist/idotmatrix/idotmatrix-settings.js +57 -0
  195. package/dist/idotmatrix/idotmatrix-settings.js.map +1 -0
  196. package/dist/index.d.ts.map +1 -1
  197. package/dist/index.js +217 -179
  198. package/dist/index.js.map +1 -1
  199. package/dist/log-stream.d.ts +15 -16
  200. package/dist/log-stream.d.ts.map +1 -1
  201. package/dist/log-stream.js +18 -96
  202. package/dist/log-stream.js.map +1 -1
  203. package/dist/logger.d.ts +3 -1
  204. package/dist/logger.d.ts.map +1 -1
  205. package/dist/logger.js +18 -5
  206. package/dist/logger.js.map +1 -1
  207. package/dist/mdns.d.ts +17 -0
  208. package/dist/mdns.d.ts.map +1 -1
  209. package/dist/mdns.js +52 -18
  210. package/dist/mdns.js.map +1 -1
  211. package/dist/model-catalog.d.ts +1 -1
  212. package/dist/model-catalog.d.ts.map +1 -1
  213. package/dist/model-catalog.js +2 -4
  214. package/dist/model-catalog.js.map +1 -1
  215. package/dist/modules/adb-module.d.ts +1 -1
  216. package/dist/modules/adb-module.d.ts.map +1 -1
  217. package/dist/modules/adb-module.js +4 -3
  218. package/dist/modules/adb-module.js.map +1 -1
  219. package/dist/modules/broadcast-module.d.ts +17 -0
  220. package/dist/modules/broadcast-module.d.ts.map +1 -0
  221. package/dist/modules/broadcast-module.js +26 -0
  222. package/dist/modules/broadcast-module.js.map +1 -0
  223. package/dist/modules/d200h-module.d.ts +22 -0
  224. package/dist/modules/d200h-module.d.ts.map +1 -1
  225. package/dist/modules/d200h-module.js +200 -65
  226. package/dist/modules/d200h-module.js.map +1 -1
  227. package/dist/modules/idotmatrix-module.d.ts +9 -0
  228. package/dist/modules/idotmatrix-module.d.ts.map +1 -0
  229. package/dist/modules/idotmatrix-module.js +46 -0
  230. package/dist/modules/idotmatrix-module.js.map +1 -0
  231. package/dist/modules/index.d.ts +3 -1
  232. package/dist/modules/index.d.ts.map +1 -1
  233. package/dist/modules/index.js +9 -3
  234. package/dist/modules/index.js.map +1 -1
  235. package/dist/modules/pixoo-module.d.ts.map +1 -1
  236. package/dist/modules/pixoo-module.js +14 -4
  237. package/dist/modules/pixoo-module.js.map +1 -1
  238. package/dist/modules/serial-module.d.ts +4 -0
  239. package/dist/modules/serial-module.d.ts.map +1 -1
  240. package/dist/modules/serial-module.js +14 -12
  241. package/dist/modules/serial-module.js.map +1 -1
  242. package/dist/modules/timebox-module.d.ts +9 -0
  243. package/dist/modules/timebox-module.d.ts.map +1 -0
  244. package/dist/modules/timebox-module.js +46 -0
  245. package/dist/modules/timebox-module.js.map +1 -0
  246. package/dist/modules/trmnl-module.d.ts +38 -0
  247. package/dist/modules/trmnl-module.d.ts.map +1 -0
  248. package/dist/modules/trmnl-module.js +111 -0
  249. package/dist/modules/trmnl-module.js.map +1 -0
  250. package/dist/modules/types.d.ts +4 -2
  251. package/dist/modules/types.d.ts.map +1 -1
  252. package/dist/observed-steering.d.ts +93 -0
  253. package/dist/observed-steering.d.ts.map +1 -0
  254. package/dist/observed-steering.js +247 -0
  255. package/dist/observed-steering.js.map +1 -0
  256. package/dist/openclaw-session.d.ts +18 -0
  257. package/dist/openclaw-session.d.ts.map +1 -0
  258. package/dist/openclaw-session.js +34 -0
  259. package/dist/openclaw-session.js.map +1 -0
  260. package/dist/opencode-steering.d.ts +36 -0
  261. package/dist/opencode-steering.d.ts.map +1 -0
  262. package/dist/opencode-steering.js +84 -0
  263. package/dist/opencode-steering.js.map +1 -0
  264. package/dist/output-parser.d.ts +30 -0
  265. package/dist/output-parser.d.ts.map +1 -1
  266. package/dist/output-parser.js +172 -18
  267. package/dist/output-parser.js.map +1 -1
  268. package/dist/passive-observer.d.ts +32 -0
  269. package/dist/passive-observer.d.ts.map +1 -1
  270. package/dist/passive-observer.js +288 -49
  271. package/dist/passive-observer.js.map +1 -1
  272. package/dist/permission-resolver.d.ts +55 -0
  273. package/dist/permission-resolver.d.ts.map +1 -0
  274. package/dist/permission-resolver.js +112 -0
  275. package/dist/permission-resolver.js.map +1 -0
  276. package/dist/pixoo/micro-glyphs.d.ts +18 -0
  277. package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
  278. package/dist/pixoo/micro-glyphs.js +143 -0
  279. package/dist/pixoo/micro-glyphs.js.map +1 -0
  280. package/dist/pixoo/official-dot-glyphs.generated.d.ts +8 -0
  281. package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -0
  282. package/dist/pixoo/official-dot-glyphs.generated.js +247 -0
  283. package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -0
  284. package/dist/pixoo/pixoo-bridge.d.ts +16 -8
  285. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  286. package/dist/pixoo/pixoo-bridge.js +127 -35
  287. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  288. package/dist/pixoo/pixoo-camera.d.ts +19 -3
  289. package/dist/pixoo/pixoo-camera.d.ts.map +1 -1
  290. package/dist/pixoo/pixoo-camera.js +62 -27
  291. package/dist/pixoo/pixoo-camera.js.map +1 -1
  292. package/dist/pixoo/pixoo-client.d.ts +13 -3
  293. package/dist/pixoo/pixoo-client.d.ts.map +1 -1
  294. package/dist/pixoo/pixoo-client.js +51 -22
  295. package/dist/pixoo/pixoo-client.js.map +1 -1
  296. package/dist/pixoo/pixoo-discover.d.ts +37 -0
  297. package/dist/pixoo/pixoo-discover.d.ts.map +1 -0
  298. package/dist/pixoo/pixoo-discover.js +105 -0
  299. package/dist/pixoo/pixoo-discover.js.map +1 -0
  300. package/dist/pixoo/pixoo-renderer.d.ts +27 -3
  301. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  302. package/dist/pixoo/pixoo-renderer.js +533 -135
  303. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  304. package/dist/pixoo/pixoo-settings.d.ts +6 -0
  305. package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
  306. package/dist/pixoo/pixoo-settings.js +9 -0
  307. package/dist/pixoo/pixoo-settings.js.map +1 -1
  308. package/dist/pixoo/pixoo-sprites.d.ts +84 -11
  309. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  310. package/dist/pixoo/pixoo-sprites.js +620 -209
  311. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  312. package/dist/pty-manager.d.ts.map +1 -1
  313. package/dist/pty-manager.js +8 -2
  314. package/dist/pty-manager.js.map +1 -1
  315. package/dist/review-runner.d.ts +113 -0
  316. package/dist/review-runner.d.ts.map +1 -0
  317. package/dist/review-runner.js +428 -0
  318. package/dist/review-runner.js.map +1 -0
  319. package/dist/session-activity.d.ts +14 -0
  320. package/dist/session-activity.d.ts.map +1 -0
  321. package/dist/session-activity.js +160 -0
  322. package/dist/session-activity.js.map +1 -0
  323. package/dist/session-aggregator.d.ts +25 -0
  324. package/dist/session-aggregator.d.ts.map +1 -1
  325. package/dist/session-aggregator.js +48 -9
  326. package/dist/session-aggregator.js.map +1 -1
  327. package/dist/session-focus-relay.d.ts.map +1 -1
  328. package/dist/session-focus-relay.js +7 -1
  329. package/dist/session-focus-relay.js.map +1 -1
  330. package/dist/session-registry.d.ts +74 -3
  331. package/dist/session-registry.d.ts.map +1 -1
  332. package/dist/session-registry.js +140 -8
  333. package/dist/session-registry.js.map +1 -1
  334. package/dist/session-timeline-relay.d.ts.map +1 -1
  335. package/dist/session-timeline-relay.js +13 -4
  336. package/dist/session-timeline-relay.js.map +1 -1
  337. package/dist/session-transcript-timeline.d.ts +49 -0
  338. package/dist/session-transcript-timeline.d.ts.map +1 -0
  339. package/dist/session-transcript-timeline.js +276 -0
  340. package/dist/session-transcript-timeline.js.map +1 -0
  341. package/dist/state-machine.d.ts +6 -1
  342. package/dist/state-machine.d.ts.map +1 -1
  343. package/dist/state-machine.js +86 -28
  344. package/dist/state-machine.js.map +1 -1
  345. package/dist/timebox/timebox-daemon-sync.d.ts +19 -0
  346. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -0
  347. package/dist/timebox/timebox-daemon-sync.js +135 -0
  348. package/dist/timebox/timebox-daemon-sync.js.map +1 -0
  349. package/dist/timebox/timebox-discover.d.ts +19 -0
  350. package/dist/timebox/timebox-discover.d.ts.map +1 -0
  351. package/dist/timebox/timebox-discover.js +87 -0
  352. package/dist/timebox/timebox-discover.js.map +1 -0
  353. package/dist/timebox/timebox-settings.d.ts +32 -0
  354. package/dist/timebox/timebox-settings.d.ts.map +1 -0
  355. package/dist/timebox/timebox-settings.js +80 -0
  356. package/dist/timebox/timebox-settings.js.map +1 -0
  357. package/dist/timeline-store.d.ts +112 -3
  358. package/dist/timeline-store.d.ts.map +1 -1
  359. package/dist/timeline-store.js +460 -14
  360. package/dist/timeline-store.js.map +1 -1
  361. package/dist/timeline-summarizer.d.ts.map +1 -1
  362. package/dist/timeline-summarizer.js +29 -5
  363. package/dist/timeline-summarizer.js.map +1 -1
  364. package/dist/trmnl/byos-server.d.ts +39 -0
  365. package/dist/trmnl/byos-server.d.ts.map +1 -0
  366. package/dist/trmnl/byos-server.js +301 -0
  367. package/dist/trmnl/byos-server.js.map +1 -0
  368. package/dist/trmnl/frame-cache.d.ts +37 -0
  369. package/dist/trmnl/frame-cache.d.ts.map +1 -0
  370. package/dist/trmnl/frame-cache.js +157 -0
  371. package/dist/trmnl/frame-cache.js.map +1 -0
  372. package/dist/trmnl/image-renderer.d.ts +27 -0
  373. package/dist/trmnl/image-renderer.d.ts.map +1 -0
  374. package/dist/trmnl/image-renderer.js +235 -0
  375. package/dist/trmnl/image-renderer.js.map +1 -0
  376. package/dist/trmnl/trmnl-settings.d.ts +96 -0
  377. package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
  378. package/dist/trmnl/trmnl-settings.js +177 -0
  379. package/dist/trmnl/trmnl-settings.js.map +1 -0
  380. package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
  381. package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
  382. package/dist/trmnl/trmnl-telemetry.js +56 -0
  383. package/dist/trmnl/trmnl-telemetry.js.map +1 -0
  384. package/dist/tui/ansi.d.ts +0 -4
  385. package/dist/tui/ansi.d.ts.map +1 -1
  386. package/dist/tui/ansi.js +14 -7
  387. package/dist/tui/ansi.js.map +1 -1
  388. package/dist/tui/dashboard.d.ts +1 -0
  389. package/dist/tui/dashboard.d.ts.map +1 -1
  390. package/dist/tui/dashboard.js +38 -11
  391. package/dist/tui/dashboard.js.map +1 -1
  392. package/dist/tui/renderer.d.ts +36 -0
  393. package/dist/tui/renderer.d.ts.map +1 -1
  394. package/dist/tui/renderer.js +355 -121
  395. package/dist/tui/renderer.js.map +1 -1
  396. package/dist/tui/terrarium.d.ts.map +1 -1
  397. package/dist/tui/terrarium.js +44 -51
  398. package/dist/tui/terrarium.js.map +1 -1
  399. package/dist/types.d.ts +2 -2
  400. package/dist/types.d.ts.map +1 -1
  401. package/dist/types.js +2 -2
  402. package/dist/types.js.map +1 -1
  403. package/dist/usage-api.d.ts.map +1 -1
  404. package/dist/usage-api.js +7 -1
  405. package/dist/usage-api.js.map +1 -1
  406. package/dist/usage-event.d.ts +10 -3
  407. package/dist/usage-event.d.ts.map +1 -1
  408. package/dist/usage-event.js +98 -13
  409. package/dist/usage-event.js.map +1 -1
  410. package/dist/utility-proxy.d.ts.map +1 -1
  411. package/dist/utility-proxy.js +8 -0
  412. package/dist/utility-proxy.js.map +1 -1
  413. package/dist/utils/project-name.d.ts +3 -0
  414. package/dist/utils/project-name.d.ts.map +1 -1
  415. package/dist/utils/project-name.js +44 -2
  416. package/dist/utils/project-name.js.map +1 -1
  417. package/dist/version-check.d.ts.map +1 -1
  418. package/dist/version-check.js +1 -17
  419. package/dist/version-check.js.map +1 -1
  420. package/dist/windows-service.d.ts +26 -0
  421. package/dist/windows-service.d.ts.map +1 -0
  422. package/dist/windows-service.js +145 -0
  423. package/dist/windows-service.js.map +1 -0
  424. package/dist/ws-server.d.ts +17 -3
  425. package/dist/ws-server.d.ts.map +1 -1
  426. package/dist/ws-server.js +78 -8
  427. package/dist/ws-server.js.map +1 -1
  428. package/fm-helper/AgentDeckFMHelper.swift +112 -0
  429. package/package.json +10 -4
  430. package/scripts/build-fm-helper.mjs +26 -0
  431. package/LICENSE +0 -21
  432. package/dist/apme/tuner.d.ts +0 -64
  433. package/dist/apme/tuner.d.ts.map +0 -1
  434. package/dist/apme/tuner.js +0 -297
  435. package/dist/apme/tuner.js.map +0 -1
@@ -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 { createWriteStream, createReadStream } from 'fs';
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-\d+/, // CP210x / CH340 (cu.usbserial-XXXX)
21
- /\/dev\/cu\.wchusbserial\d+/, // CH340 (cu.wchusbserialXXXX — 86 Box)
22
- /\/dev\/cu\.usbmodem\d+/, // Native USB JTAG (IPS 3.5", Round AMOLED)
23
- /\/dev\/ttyUSB\d+/, // Linux CH340
24
- /\/dev\/ttyACM\d+/, // Linux native USB
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
- export function prepareForSerial(event) {
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
- // Pre-format ISO dates + strip unused fields
52
- const { ollamaStatus, tokenStatus, extraUsageEnabled, extraUsageMonthlyLimit, extraUsageUsedCredits, extraUsageUtilization, costSpent, costLimit, sessionPercent, resetTime, resetDate, ...keep } = e;
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
- ...keep,
55
- fiveHourResetsAt: formatResetTime(keep.fiveHourResetsAt),
56
- sevenDayResetsAt: formatResetTime(keep.sevenDayResetsAt),
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
- // Strip agentCapabilities + other fields ESP32 doesn't parse
61
- const { agentCapabilities, billingType, remoteUrl, diff, context, selectedOptionIndex, ...keep } = e;
62
- return keep;
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
- // When exec timeout fires, it sends SIGTERM. But stty stuck in kernel I/O
78
- // ignores SIGTERM. Schedule a SIGKILL as escalation.
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
- return; // Skip debug output like "[WiFi] Connected"
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 openPort(port) {
140
- try {
141
- // Open streams first, THEN configure baud rate.
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 reader = createReadStream(port, { flags: 'r', encoding: 'utf-8' });
164
- reader.pause(); // Don't process data until baud rate is configured
165
- conn.reader = reader;
166
- reader.on('data', (chunk) => {
167
- const str = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
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
- reader.on('error', (err) => {
183
- debug('ESP32', `Serial read error on ${port}: ${err.message}`);
184
- conn.connected = false;
185
- try {
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
- debug('ESP32', `Failed to open reader on ${port}: ${err.message}`);
201
- // Write-only still works for broadcast
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 if (platform === 'linux') {
214
- await execWithKill(`stty -F ${port} 115200 cs8 -cstopb -parenb -hupcl`);
695
+ else {
696
+ debug('ESP32', `Serial read error on ${conn.port}: ${err.message}`);
697
+ closeConnection(conn);
698
+ break;
215
699
  }
216
700
  }
217
- // Resume reader now that baud rate is configured (flush any garbled data from open→stty window)
218
- if (conn.reader) {
219
- conn.readBuf = '';
220
- conn.reader.resume();
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
- sendToConnection(conn, JSON.stringify({ type: 'device_info_request' }));
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
- sendToConnection(conn, JSON.stringify(prepareForSerial(event)));
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
- debug('ESP32', `Failed to open ${port}: ${err.message}`);
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 sendToConnection(conn, json) {
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 (conn.stream.destroyed || !conn.stream.writable) {
255
- conn.connected = false;
256
- return;
786
+ if (priority) {
787
+ conn.writeQueue.unshift(json);
257
788
  }
258
- try {
259
- conn.stream.write(json + '\n');
789
+ else {
790
+ conn.writeQueue.push(json);
260
791
  }
261
- catch {
262
- conn.connected = false;
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, json);
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 if API usage data is present (fiveHourPercent defined),
311
- // otherwise the ESP32 would reset its cached values to "no data" sentinel.
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
- if (event && event.fiveHourPercent != null) {
315
- const json = JSON.stringify(prepareForSerial(event));
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
- sendToConnection(conn, json);
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
- const ports = await detectESP32Ports();
347
- // Remove disconnected
348
- connections = connections.filter(c => {
349
- if (!c.connected) {
350
- try {
351
- c.stream.end();
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
- catch { /* ignore */ }
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
- c.reader?.destroy();
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
- catch { /* ignore */ }
358
- return false;
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
- sendToConnection(conn, json);
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.connected)
415
- continue;
416
- // Skip if already provisioned or WiFi already configured
417
- if (conn.provisionSent)
1272
+ if (!shouldSendWifiProvision(conn))
418
1273
  continue;
419
- if (conn.deviceInfo?.wifiConnected)
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.connected)
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
- try {
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
- try {
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
- return connections.filter(c => c.connected).length;
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
- return connections.filter(c => c.connected).map(c => c.port);
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