@trillboards/edge-sdk 0.2.40 → 0.2.41

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 (82) hide show
  1. package/README.md +1 -1
  2. package/deploy/docker/{Dockerfile.cloud-sense → Dockerfile.connector} +52 -39
  3. package/deploy/docker/README.md +73 -1
  4. package/deploy/docker/connector-entrypoint.sh +46 -80
  5. package/deploy/macos/README.md +1 -1
  6. package/deploy/macos/test-install-pair.sh +9 -9
  7. package/deploy/systemd/install.sh +16 -1
  8. package/deploy/systemd/setup-litertlm.sh +201 -0
  9. package/deploy/systemd/trillboards-edge.service +8 -4
  10. package/deploy/systemd/trillboards-litertlm.service +46 -0
  11. package/deploy/windows/register-task.ps1 +6 -3
  12. package/deploy/windows/run-connector.ps1 +1 -1
  13. package/deploy/windows/trillboards-edge-task.vbs +5 -1
  14. package/dist/CloudSensingManager.d.ts +45 -19
  15. package/dist/CloudSensingManager.d.ts.map +1 -1
  16. package/dist/CloudSensingManager.js +89 -46
  17. package/dist/CloudSensingManager.js.map +1 -1
  18. package/dist/EdgeAgent.js +1 -1
  19. package/dist/SensingProfileClient.d.ts +9 -0
  20. package/dist/SensingProfileClient.d.ts.map +1 -1
  21. package/dist/SensingProfileClient.js +4 -0
  22. package/dist/SensingProfileClient.js.map +1 -1
  23. package/dist/SetupSessionClient.d.ts +0 -1
  24. package/dist/SetupSessionClient.d.ts.map +1 -1
  25. package/dist/autoSense.d.ts +42 -31
  26. package/dist/autoSense.d.ts.map +1 -1
  27. package/dist/autoSense.js +127 -99
  28. package/dist/autoSense.js.map +1 -1
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +25 -84
  31. package/dist/cli.js.map +1 -1
  32. package/dist/combinedSense.d.ts +1 -3
  33. package/dist/combinedSense.d.ts.map +1 -1
  34. package/dist/combinedSense.js +0 -1
  35. package/dist/combinedSense.js.map +1 -1
  36. package/dist/config.d.ts +17 -64
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +0 -10
  39. package/dist/config.js.map +1 -1
  40. package/dist/deployedCadence.d.ts +37 -75
  41. package/dist/deployedCadence.d.ts.map +1 -1
  42. package/dist/deployedCadence.js +45 -105
  43. package/dist/deployedCadence.js.map +1 -1
  44. package/dist/deployedCadenceSource.d.ts +0 -2
  45. package/dist/deployedCadenceSource.d.ts.map +1 -1
  46. package/dist/deployedCadenceSource.js +0 -1
  47. package/dist/deployedCadenceSource.js.map +1 -1
  48. package/dist/diagnose.d.ts +1 -1
  49. package/dist/diagnose.js +1 -1
  50. package/dist/index.d.ts +0 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -8
  53. package/dist/index.js.map +1 -1
  54. package/dist/localInferenceSchema.d.ts +19 -0
  55. package/dist/localInferenceSchema.d.ts.map +1 -1
  56. package/dist/localInferenceSchema.js +23 -0
  57. package/dist/localInferenceSchema.js.map +1 -1
  58. package/dist/localSensing.d.ts +48 -107
  59. package/dist/localSensing.d.ts.map +1 -1
  60. package/dist/localSensing.js +80 -171
  61. package/dist/localSensing.js.map +1 -1
  62. package/dist/sensingCatchRate.d.ts +246 -0
  63. package/dist/sensingCatchRate.d.ts.map +1 -0
  64. package/dist/sensingCatchRate.js +514 -0
  65. package/dist/sensingCatchRate.js.map +1 -0
  66. package/dist/sensingRunMode.d.ts +14 -11
  67. package/dist/sensingRunMode.d.ts.map +1 -1
  68. package/dist/sensingRunMode.js +9 -92
  69. package/dist/sensingRunMode.js.map +1 -1
  70. package/dist/setupMode.d.ts +0 -1
  71. package/dist/setupMode.d.ts.map +1 -1
  72. package/dist/setupMode.js +5 -3
  73. package/dist/setupMode.js.map +1 -1
  74. package/dist/startupDiscovery.d.ts +43 -0
  75. package/dist/startupDiscovery.d.ts.map +1 -0
  76. package/dist/startupDiscovery.js +72 -0
  77. package/dist/startupDiscovery.js.map +1 -0
  78. package/package.json +1 -1
  79. package/dist/CloudEscalationPolicy.d.ts +0 -143
  80. package/dist/CloudEscalationPolicy.d.ts.map +0 -1
  81. package/dist/CloudEscalationPolicy.js +0 -137
  82. package/dist/CloudEscalationPolicy.js.map +0 -1
@@ -47,9 +47,16 @@ export declare function probeLiteRtLmHealth(baseUrl?: string, opts?: {
47
47
  }): Promise<LiteRtHealthResult>;
48
48
  /** A timer handle the supervisor sets for its re-probe loop (injectable in tests). */
49
49
  export type AutoSenseTimerHandle = ReturnType<typeof setTimeout>;
50
- /** Which sensing path the supervisor is currently running. */
51
- export type SensingMode = 'local' | 'cloud';
52
- export type AutoSenseState = SensingMode | 'waiting';
50
+ /**
51
+ * Which sensing path the supervisor is currently running.
52
+ *
53
+ * `local` analyzes on-device. `waiting` runs the same manager with inference
54
+ * switched off — cameras open, health reported, nothing analyzed — because a
55
+ * connector that cannot sense yet must still be visibly alive. There is no
56
+ * `cloud`: see the module header.
57
+ */
58
+ export type SensingMode = 'local' | 'waiting';
59
+ export type AutoSenseState = SensingMode;
53
60
  /**
54
61
  * Everything the AutoSenseSupervisor needs, all injectable so the supervisor can
55
62
  * be unit-tested with a fake probe + fake managers. `runAutoSense` builds this
@@ -60,8 +67,6 @@ export interface AutoSenseSupervisorDeps {
60
67
  probe: () => Promise<LiteRtHealthResult>;
61
68
  /** Build (NOT start) the local-sensing manager. `null` ⇒ no feeds (fatal). */
62
69
  buildLocalManager: () => SensingManager | null;
63
- /** Build (NOT start) the cloud-sensing manager. `null` ⇒ no feeds (fatal). */
64
- buildCloudManager: () => SensingManager | null;
65
70
  /** Log sink (tests capture it). */
66
71
  log: (msg: string) => void;
67
72
  /** Sleep between startup re-probes (tests inject a no-op). */
@@ -80,10 +85,8 @@ export interface AutoSenseSupervisorDeps {
80
85
  startupBackoffMs: number;
81
86
  /** Steady-state re-probe interval (ms), already floored. */
82
87
  recheckIntervalMs: number;
83
- /** Consecutive unhealthy re-probes required before a LOCAL→CLOUD fallback. */
88
+ /** Consecutive unhealthy re-probes required before the runtime is called unhealthy. */
84
89
  unhealthyChecksBeforeFallback: number;
85
- /** Cloud inference is opt-in because continuous CCTV cloud load is expensive. */
86
- allowCloudFallback: boolean;
87
90
  /** For the initial "probing…" log line. */
88
91
  litertLmBaseUrl: string;
89
92
  expectedModel: string;
@@ -95,13 +98,12 @@ export interface AutoSenseSupervisorDeps {
95
98
  * "restart to re-probe"), it OWNS the sensing lifecycle so the connector always
96
99
  * prefers on-device Gemma and switches automatically with NO restart:
97
100
  *
98
- * - startInitial(): bounded startup re-probe → start the chosen path's REAL
99
- * manager directly when litert-lm is healthy; otherwise wait locally unless
100
- * cloud fallback was explicitly enabled.
101
+ * - startInitial(): bounded startup re-probe → start the REAL manager with
102
+ * inference ON when litert-lm is healthy, or in `healthOnly` mode when it is
103
+ * not, so a waiting connector still reports its cameras.
101
104
  * - beginRechecks(): a steady-state background re-probe loop on recheckIntervalMs.
102
- * - recheck(): one tick — AUTO-UPGRADE cloud→local the instant local is ready
103
- * (fixes the fresh-install race with zero restart). Explicit cloud-fallback
104
- * installs may also recover local→cloud after K unhealthy probes.
105
+ * - recheck(): one tick — AUTO-UPGRADE waiting→local the instant the model is
106
+ * ready (fixes the fresh-install race with zero restart).
105
107
  * - shutdown(): stop the active manager + clear the timer (SIGINT/SIGTERM).
106
108
  *
107
109
  * At most ONE switch per tick; a re-entrancy guard + the K-debounce mean a
@@ -112,7 +114,6 @@ export declare class AutoSenseSupervisor {
112
114
  private mode;
113
115
  private active;
114
116
  private localManager;
115
- private cloudManager;
116
117
  private consecutiveUnhealthy;
117
118
  /**
118
119
  * Completed local analyses as of the previous re-probe. `null` = no baseline
@@ -120,6 +121,14 @@ export declare class AutoSenseSupervisor {
120
121
  * positive evidence" and leaves the pre-existing behaviour in place.
121
122
  */
122
123
  private analysesAtLastRecheck;
124
+ /**
125
+ * Why the connector is not sensing, in the probe's own words. Restated on
126
+ * every re-probe while waiting: a connector that goes quiet after ONE line at
127
+ * boot is how "waiting for a 2.4 GB model" became indistinguishable from
128
+ * "dead" in the first place, and the operator reading the log an hour later
129
+ * needs the reason to still be there.
130
+ */
131
+ private lastWaitReason;
123
132
  private timer;
124
133
  private switching;
125
134
  private stopped;
@@ -140,6 +149,7 @@ export declare class AutoSenseSupervisor {
140
149
  inFlight: number;
141
150
  buffered: number;
142
151
  };
152
+ /** The one manager, built on first use. Same instance for both states. */
143
153
  private getManager;
144
154
  /**
145
155
  * Initial probe (bounded startup re-probe loop for the fresh-install warmup
@@ -147,7 +157,13 @@ export declare class AutoSenseSupervisor {
147
157
  * onNoFeeds) when the chosen mode has no feeds configured.
148
158
  */
149
159
  startInitial(): Promise<boolean>;
150
- /** Build + start a mode's manager and make it active. False ⇒ no feeds. */
160
+ /**
161
+ * Start the manager in the given mode and make it active. False ⇒ no feeds.
162
+ *
163
+ * `waiting` starts the SAME manager with `healthOnly`, so the feeds are
164
+ * validated and the cameras are opened either way — a bad/empty config still
165
+ * fails immediately instead of looking like a model download in progress.
166
+ */
151
167
  private activate;
152
168
  /** Start the steady-state background re-probe loop. */
153
169
  beginRechecks(): void;
@@ -161,12 +177,9 @@ export declare class AutoSenseSupervisor {
161
177
  /**
162
178
  * One background re-probe tick.
163
179
  * - While WAITING: a healthy probe starts local sensing immediately.
164
- * - On CLOUD: any healthy probe → AUTO-UPGRADE to local immediately (running
165
- * on cloud while local is ready is pure waste; the K-debounce on the return
166
- * path bounds any toggling, so this is safe on the first healthy probe).
167
180
  * - On LOCAL: a healthy probe resets the debounce; an unhealthy probe
168
- * increments it, and only after K CONSECUTIVE unhealthy probes do we
169
- * AUTO-RECOVER to cloud only when explicit fallback is enabled.
181
+ * increments it, and only after K CONSECUTIVE unhealthy probes do we report
182
+ * the runtime unhealthy — sensing stays put and recovers in place.
170
183
  * At most one switch per tick; re-entrancy guarded.
171
184
  *
172
185
  * ── Why an unanswered probe is not, by itself, a verdict ───────────────────
@@ -194,11 +207,11 @@ export declare class AutoSenseSupervisor {
194
207
  * landing; the moment they stop landing the same probe counts, and K of them
195
208
  * demote exactly as before.
196
209
  *
197
- * It has to be `localInferences` and not the total analysis count. With
198
- * `cloudFallbackOnError` on, a frame the local provider THREW on is answered
199
- * by the cloud and still lands as an analysis — so a dead runtime would keep
200
- * advancing the total forever and this rule would mask it permanently, which
201
- * is strictly worse than the defect it replaces.
210
+ * It has to be `localInferences` and not the total analysis count, and that
211
+ * distinction must be preserved even though the connector no longer has a
212
+ * cloud path that could advance the total on its own: this counter is the
213
+ * supervisor's only proof of life, so anything but a COMPLETED on-device
214
+ * inference advancing it would make the proof self-fulfilling.
202
215
  *
203
216
  * Deliberately NOT keyed on "we have a request in flight": a serve that
204
217
  * ACCEPTS and never answers holds our permit for the provider's whole timeout,
@@ -216,7 +229,7 @@ export declare class AutoSenseSupervisor {
216
229
  /** Injection points for runAutoSense (default to the real probe/managers/timers). */
217
230
  export interface AutoSenseDeps {
218
231
  /**
219
- * Probe used to decide local-vs-cloud. Defaults to probeLiteRtLmHealth.
232
+ * Probe used to decide sense-vs-wait. Defaults to probeLiteRtLmHealth.
220
233
  * Receives the resolved base URL, per-probe timeout, and the model the local
221
234
  * path will run (so the probe can require it before declaring local usable).
222
235
  */
@@ -225,8 +238,6 @@ export interface AutoSenseDeps {
225
238
  loadConfig?: (configPath: string) => EdgeAgentConfig;
226
239
  /** Build (NOT start) the local manager. Defaults to buildLocalSensingManager(config). */
227
240
  buildLocalManager?: () => SensingManager | null;
228
- /** Build (NOT start) the cloud manager. Defaults to buildCloudSensingManager(config). */
229
- buildCloudManager?: () => SensingManager | null;
230
241
  /** Log sink (tests capture it). Defaults to console.log. */
231
242
  log?: (msg: string) => void;
232
243
  /** Sleep between startup re-probes (tests inject a no-op). Defaults to setTimeout. */
@@ -264,8 +275,8 @@ export interface AutoSenseDeps {
264
275
  * genuine config error propagates to main() (prints + exits 1) — the same net
265
276
  * result as before, where the chosen run mode re-loaded the config and reported it.
266
277
  *
267
- * The explicit `local-sense` / `cloud-sense` entrypoints stay non-switching; only
268
- * `auto-sense` is supervised.
278
+ * The explicit `local-sense` entrypoint stays non-switching; only `auto-sense`
279
+ * is supervised.
269
280
  */
270
281
  export declare function runAutoSense(args: string[], deps?: AutoSenseDeps): Promise<void>;
271
282
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"autoSense.d.ts","sourceRoot":"","sources":["../src/autoSense.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"autoSense.d.ts","sourceRoot":"","sources":["../src/autoSense.ts"],"names":[],"mappings":"AAGA,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,UAAU,CAAC;AAE5D,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACjF,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AA8F/E,+EAA+E;AAC/E,MAAM,WAAW,kBAAkB;IACjC,8EAA8E;IAC9E,EAAE,EAAE,OAAO,CAAC;IACZ,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qFAAqF;AACrF,MAAM,WAAW,UAAU;IACzB,CACE,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAC7B,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,GAAE,MAAmC,EAC5C,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAO,GACjF,OAAO,CAAC,kBAAkB,CAAC,CA0G7B;AAED,sFAAsF;AACtF,MAAM,MAAM,oBAAoB,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAEjE;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,SAAS,CAAC;AAC9C,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC;AAEzC;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,iFAAiF;IACjF,KAAK,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACzC,8EAA8E;IAC9E,iBAAiB,EAAE,MAAM,cAAc,GAAG,IAAI,CAAC;IAC/C,mCAAmC;IACnC,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3B,8DAA8D;IAC9D,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,8EAA8E;IAC9E,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,oBAAoB,CAAC;IAC/D,gEAAgE;IAChE,UAAU,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACnD,sFAAsF;IACtF,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,yGAAyG;IACzG,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9B,+EAA+E;IAC/E,eAAe,EAAE,MAAM,CAAC;IACxB,4CAA4C;IAC5C,gBAAgB,EAAE,MAAM,CAAC;IACzB,4DAA4D;IAC5D,iBAAiB,EAAE,MAAM,CAAC;IAC1B,uFAAuF;IACvF,6BAA6B,EAAE,MAAM,CAAC;IACtC,2CAA2C;IAC3C,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,mBAAmB;IA4BlB,OAAO,CAAC,QAAQ,CAAC,IAAI;IA3BjC,OAAO,CAAC,IAAI,CAA+B;IAC3C,OAAO,CAAC,MAAM,CAA+B;IAM7C,OAAO,CAAC,YAAY,CAAgD;IACpE,OAAO,CAAC,oBAAoB,CAAK;IACjC;;;;OAIG;IACH,OAAO,CAAC,qBAAqB,CAAuB;IACpD;;;;;;OAMG;IACH,OAAO,CAAC,cAAc,CAAuB;IAC7C,OAAO,CAAC,KAAK,CAAqC;IAClD,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,OAAO,CAAS;gBAEK,IAAI,EAAE,uBAAuB;IAE1D,0EAA0E;IAC1E,IAAI,WAAW,IAAI,cAAc,GAAG,IAAI,CAEvC;IAED;;;;;;;;;OASG;IACH,eAAe,IAAI;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE;IAazD,0EAA0E;IAC1E,OAAO,CAAC,UAAU;IAKlB;;;;OAIG;IACG,YAAY,IAAI,OAAO,CAAC,OAAO,CAAC;IAmCtC;;;;;;OAMG;YACW,QAAQ;IAkBtB,uDAAuD;IACvD,aAAa,IAAI,IAAI;IAIrB,OAAO,CAAC,mBAAmB;IAe3B;;;;OAIG;IACH,OAAO,CAAC,wBAAwB;IAWhC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6CG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAiE9B,iFAAiF;YACnE,QAAQ;IA0BtB,sFAAsF;IAChF,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAehC;AAED,qFAAqF;AACrF,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACnG,0DAA0D;IAC1D,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,eAAe,CAAC;IACrD,yFAAyF;IACzF,iBAAiB,CAAC,EAAE,MAAM,cAAc,GAAG,IAAI,CAAC;IAChD,4DAA4D;IAC5D,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,sFAAsF;IACtF,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,sDAAsD;IACtD,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,oBAAoB,CAAC;IAChE,0DAA0D;IAC1D,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACpD,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC;IACjE,wFAAwF;IACxF,eAAe,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,mBAAmB,GAAG,IAAI,CAAC;CACzE;AAYD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2G1F;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,UAyB/B,CAAC"}
package/dist/autoSense.js CHANGED
@@ -43,6 +43,7 @@ const config_1 = require("./config");
43
43
  const LiteRtVlmProvider_1 = require("./LiteRtVlmProvider");
44
44
  const sensingRunMode_1 = require("./sensingRunMode");
45
45
  const gemmaModelOta_1 = require("./gemmaModelOta");
46
+ const startupDiscovery_1 = require("./startupDiscovery");
46
47
  const connectorUpdate_1 = require("./connectorUpdate");
47
48
  /**
48
49
  * This install's version, read from package.json rather than declared as a
@@ -61,18 +62,39 @@ function connectorVersion() {
61
62
  }
62
63
  }
63
64
  /**
64
- * `auto-sense` — the ONE dispatching entrypoint for the BYO-CCTV connector that
65
- * prefers ON-DEVICE local sensing (LiteRT-LM serving Gemma). Cloud inference is
66
- * available only when the operator explicitly enables it.
65
+ * `auto-sense` — the ONE dispatching entrypoint for the BYO-CCTV connector.
66
+ * The connector is LOCAL-INFERENCE ONLY: it runs Gemma on-device via LiteRT-LM
67
+ * and there is no cloud VLM path in it at all.
67
68
  *
68
69
  * A fresh install may start before its model download finishes, so this probes
69
- * `litert-lm serve` health with a SHORT timeout and:
70
- * - healthy → runs the EXISTING local-sense path (no Vertex cost)
71
- * - unhealthy/unreachable → waits and keeps probing without uploading frames
72
- * - unhealthy + allowCloudFallback=true → runs the cloud-sense path
70
+ * `litert-lm serve` health with a SHORT timeout and runs one of TWO states:
71
+ * - healthy → `local`: the full on-device sensing path
72
+ * - otherwise → `waiting`: cameras open, video proven, camera health reported
73
+ * on the heartbeat, and NOTHING analyzed or uploaded
73
74
  *
74
- * It introduces NO new providers and NO duplicated sensing logic — the local
75
- * and cloud run modes are reused verbatim. This module only owns the SELECTION.
75
+ * ── Why there is no third state ────────────────────────────────────────────
76
+ *
77
+ * There used to be: `cloud`, entered when `allowCloudFallback` was set or the
78
+ * local runtime went unhealthy. Measured 2026-08-12 over 255,812 image-bearing
79
+ * cloud VLM calls, that path cost $0.007008 per analyzed frame — ~$605/camera/
80
+ * month at a 30s cadence, so a 16-camera store ran ~$9,688/month against a
81
+ * $99-500 subscription. It is removed rather than rate-limited, which SUPERSEDES
82
+ * the escalation architecture from #11971 ("local Gemma decides *when* to look,
83
+ * the cloud decides *what* it saw") and the frequency-based cost control in
84
+ * plans/cctv-byo-sensing-plan.md:26. Both spent less per frame; neither changes
85
+ * the sign of a per-frame cost inside a flat-rate product.
86
+ *
87
+ * ── Why `waiting` is not idle ──────────────────────────────────────────────
88
+ *
89
+ * `waiting` used to start NO manager, so it emitted no analyze, no camera health
90
+ * and no heartbeat — a connector importing a 2.4 GB model looked exactly like a
91
+ * connector that had been unplugged, on the HAPPY path of a first boot. It now
92
+ * runs the SAME manager in `healthOnly` mode: same feeds, same cameras, same
93
+ * health reporting, zero inference. The portal sees a live connector whose
94
+ * cameras are proven and whose `sensingState` is `unavailable`.
95
+ *
96
+ * It introduces NO new providers and NO duplicated sensing logic — the local run
97
+ * mode is reused verbatim. This module only owns the SELECTION.
76
98
  */
77
99
  const DEFAULT_LITERT_LM_BASE_URL = 'http://localhost:9379';
78
100
  /** Default model the local-sense path runs (must match config.localSensing.model). */
@@ -86,10 +108,9 @@ const DEFAULT_PROBE_TIMEOUT_MS = 2000;
86
108
  /**
87
109
  * Startup re-probe budget. A fresh install races litert-lm warmup/model-download
88
110
  * (e.g. Windows `setup-litertlm.ps1` runs detached while the scheduled task can
89
- * start right after pairing). A single probe at boot would pick cloud and never
90
- * re-probe, stranding the box on cloud until a manual restart. So at startup we
91
- * re-probe a few times before entering the steady waiting state. Default 4
92
- * attempts.
111
+ * start right after pairing). A single probe at boot would drop straight into
112
+ * the waiting state and only leave it on the next re-probe, so at startup we
113
+ * re-probe a few times before settling. Default 4 attempts.
93
114
  */
94
115
  const DEFAULT_STARTUP_PROBE_ATTEMPTS = 4;
95
116
  /** Delay between startup re-probes (ms). Default 5s → ~15s warmup window. */
@@ -97,16 +118,16 @@ const DEFAULT_STARTUP_PROBE_BACKOFF_MS = 5000;
97
118
  /**
98
119
  * Steady-state background re-probe interval (ms). AFTER auto-sense picks a mode it
99
120
  * keeps probing on this cadence so it can start local inference once a fresh
100
- * install's litert-lm finishes warming up. Explicit cloud-fallback installs also
101
- * use it to upgrade cloud→local and recover local→cloud. Default 60s.
121
+ * install's litert-lm finishes warming up, and to restate WHY it is still
122
+ * waiting while it waits. Default 60s.
102
123
  */
103
124
  const DEFAULT_HEALTH_RECHECK_INTERVAL_MS = 60000;
104
125
  /** Floor for the re-probe interval (ms) so a mis-set tiny value can't hammer the serve. */
105
126
  const MIN_HEALTH_RECHECK_INTERVAL_MS = 15000;
106
127
  /**
107
- * Anti-thrash: consecutive UNHEALTHY background re-probes required before a
108
- * LOCAL→CLOUD fallback (a single flapping bad probe must not toggle the active
109
- * manager). The reverse cloud→local upgrade is immediate. Default 2.
128
+ * Anti-thrash: consecutive UNHEALTHY background re-probes required before the
129
+ * on-device runtime is REPORTED unhealthy (a single flapping bad probe must not
130
+ * raise an alarm). The waiting→local upgrade is immediate. Default 2.
110
131
  */
111
132
  const DEFAULT_LOCAL_UNHEALTHY_CHECKS_BEFORE_FALLBACK = 2;
112
133
  /**
@@ -146,10 +167,10 @@ async function probeLiteRtLmHealth(baseUrl = DEFAULT_LITERT_LM_BASE_URL, opts =
146
167
  // -> ok=false "is up but does not host model ...,gpu
147
168
  // (has: gemma-4-E2B-it.litertlm)"
148
169
  //
149
- // With cloud fallback off (the default) auto-sense then sets `mode='waiting'`
150
- // and starts NO manager — no analyze, no camera health, no heartbeat. The
151
- // connector goes completely dark on a configuration the provider documents as
152
- // valid, and says nothing.
170
+ // auto-sense then sets `mode='waiting'`, so a configuration the provider
171
+ // documents as valid would stop the connector analyzing anything. (It no
172
+ // longer goes DARK — waiting runs the cameras in health-only mode — but it
173
+ // still senses nothing, which is just as wrong for a valid config.)
153
174
  //
154
175
  // The defect is two modules disagreeing about whether `localSensing.model` may
155
176
  // carry an accelerator suffix; this makes the probe agree with the component
@@ -211,7 +232,7 @@ async function probeLiteRtLmHealth(baseUrl = DEFAULT_LITERT_LM_BASE_URL, opts =
211
232
  };
212
233
  }
213
234
  // The serve is up with model(s), but if it isn't hosting the one we'll infer
214
- // against, local would fail every frame — so treat that as unhealthy → cloud.
235
+ // against, local would fail every frame — so treat that as unhealthy.
215
236
  if (expectedModel && !modelIds.includes(expectedModel)) {
216
237
  return {
217
238
  ok: false,
@@ -236,13 +257,12 @@ async function probeLiteRtLmHealth(baseUrl = DEFAULT_LITERT_LM_BASE_URL, opts =
236
257
  * "restart to re-probe"), it OWNS the sensing lifecycle so the connector always
237
258
  * prefers on-device Gemma and switches automatically with NO restart:
238
259
  *
239
- * - startInitial(): bounded startup re-probe → start the chosen path's REAL
240
- * manager directly when litert-lm is healthy; otherwise wait locally unless
241
- * cloud fallback was explicitly enabled.
260
+ * - startInitial(): bounded startup re-probe → start the REAL manager with
261
+ * inference ON when litert-lm is healthy, or in `healthOnly` mode when it is
262
+ * not, so a waiting connector still reports its cameras.
242
263
  * - beginRechecks(): a steady-state background re-probe loop on recheckIntervalMs.
243
- * - recheck(): one tick — AUTO-UPGRADE cloud→local the instant local is ready
244
- * (fixes the fresh-install race with zero restart). Explicit cloud-fallback
245
- * installs may also recover local→cloud after K unhealthy probes.
264
+ * - recheck(): one tick — AUTO-UPGRADE waiting→local the instant the model is
265
+ * ready (fixes the fresh-install race with zero restart).
246
266
  * - shutdown(): stop the active manager + clear the timer (SIGINT/SIGTERM).
247
267
  *
248
268
  * At most ONE switch per tick; a re-entrancy guard + the K-debounce mean a
@@ -253,11 +273,12 @@ class AutoSenseSupervisor {
253
273
  this.deps = deps;
254
274
  this.mode = null;
255
275
  this.active = null;
256
- // Build each mode's manager once and reuse the instance across switches (a
257
- // stop()→start() cycle re-runs the SAME real manager). `undefined` = not built
258
- // yet; `null` = built but no feeds (fatal — cached so we don't rebuild).
276
+ // Built once and reused across switches: `waiting` and `local` are the SAME
277
+ // manager over the same feeds, differing only in whether inference runs, so a
278
+ // promotion is a stop()→start() cycle on one instance rather than two
279
+ // half-initialised copies of the fleet. `undefined` = not built yet; `null` =
280
+ // built but no feeds (fatal — cached so we don't rebuild).
259
281
  this.localManager = undefined;
260
- this.cloudManager = undefined;
261
282
  this.consecutiveUnhealthy = 0;
262
283
  /**
263
284
  * Completed local analyses as of the previous re-probe. `null` = no baseline
@@ -265,6 +286,14 @@ class AutoSenseSupervisor {
265
286
  * positive evidence" and leaves the pre-existing behaviour in place.
266
287
  */
267
288
  this.analysesAtLastRecheck = null;
289
+ /**
290
+ * Why the connector is not sensing, in the probe's own words. Restated on
291
+ * every re-probe while waiting: a connector that goes quiet after ONE line at
292
+ * boot is how "waiting for a 2.4 GB model" became indistinguishable from
293
+ * "dead" in the first place, and the operator reading the log an hour later
294
+ * needs the reason to still be there.
295
+ */
296
+ this.lastWaitReason = null;
268
297
  this.timer = null;
269
298
  this.switching = false;
270
299
  this.stopped = false;
@@ -295,15 +324,11 @@ class AutoSenseSupervisor {
295
324
  : 0,
296
325
  };
297
326
  }
298
- getManager(mode) {
299
- if (mode === 'local') {
300
- if (this.localManager === undefined)
301
- this.localManager = this.deps.buildLocalManager();
302
- return this.localManager;
303
- }
304
- if (this.cloudManager === undefined)
305
- this.cloudManager = this.deps.buildCloudManager();
306
- return this.cloudManager;
327
+ /** The one manager, built on first use. Same instance for both states. */
328
+ getManager() {
329
+ if (this.localManager === undefined)
330
+ this.localManager = this.deps.buildLocalManager();
331
+ return this.localManager;
307
332
  }
308
333
  /**
309
334
  * Initial probe (bounded startup re-probe loop for the fresh-install warmup
@@ -330,31 +355,27 @@ class AutoSenseSupervisor {
330
355
  this.deps.log(`[auto-sense] LOCAL on-device sensing selected — ${health.reason}`);
331
356
  return this.activate('local');
332
357
  }
333
- if (this.deps.allowCloudFallback) {
334
- this.deps.log(`[auto-sense] CLOUD sensing selected by explicit fallback policy (local VLM not usable after ` +
335
- `${this.deps.startupAttempts} attempt(s)) — ${health.reason}. Will AUTO-UPGRADE to LOCAL ` +
336
- `with no restart once LiteRT-LM is ready (re-probing every ${this.deps.recheckIntervalMs}ms).`);
337
- return this.activate('cloud');
338
- }
339
- // Validate feeds before entering the wait loop so a bad/empty config still
340
- // fails immediately instead of looking like a model download in progress.
341
- if (!this.getManager('local')) {
342
- this.deps.onNoFeeds();
343
- return false;
344
- }
345
- this.mode = 'waiting';
346
- this.deps.log(`[auto-sense] LOCAL AI is not ready yet — ${health.reason}. Cloud inference is disabled by ` +
347
- `default; waiting for LiteRT-LM and re-probing every ${this.deps.recheckIntervalMs}ms.`);
348
- return true;
358
+ this.deps.log(`[auto-sense] LOCAL AI is not ready yet — ${health.reason}. There is no cloud path: the ` +
359
+ `connector analyzes nothing until the on-device model is up. Starting cameras in ` +
360
+ `HEALTH-ONLY mode so the portal can see this connector is alive and why it is not ` +
361
+ `sensing, and re-probing every ${this.deps.recheckIntervalMs}ms.`);
362
+ this.lastWaitReason = health.reason;
363
+ return this.activate('waiting');
349
364
  }
350
- /** Build + start a mode's manager and make it active. False ⇒ no feeds. */
365
+ /**
366
+ * Start the manager in the given mode and make it active. False ⇒ no feeds.
367
+ *
368
+ * `waiting` starts the SAME manager with `healthOnly`, so the feeds are
369
+ * validated and the cameras are opened either way — a bad/empty config still
370
+ * fails immediately instead of looking like a model download in progress.
371
+ */
351
372
  async activate(mode) {
352
- const manager = this.getManager(mode);
373
+ const manager = this.getManager();
353
374
  if (!manager) {
354
375
  this.deps.onNoFeeds();
355
376
  return false;
356
377
  }
357
- await manager.start();
378
+ await manager.start(mode === 'waiting' ? { healthOnly: true } : undefined);
358
379
  this.active = manager;
359
380
  this.mode = mode;
360
381
  this.consecutiveUnhealthy = 0;
@@ -401,12 +422,9 @@ class AutoSenseSupervisor {
401
422
  /**
402
423
  * One background re-probe tick.
403
424
  * - While WAITING: a healthy probe starts local sensing immediately.
404
- * - On CLOUD: any healthy probe → AUTO-UPGRADE to local immediately (running
405
- * on cloud while local is ready is pure waste; the K-debounce on the return
406
- * path bounds any toggling, so this is safe on the first healthy probe).
407
425
  * - On LOCAL: a healthy probe resets the debounce; an unhealthy probe
408
- * increments it, and only after K CONSECUTIVE unhealthy probes do we
409
- * AUTO-RECOVER to cloud only when explicit fallback is enabled.
426
+ * increments it, and only after K CONSECUTIVE unhealthy probes do we report
427
+ * the runtime unhealthy — sensing stays put and recovers in place.
410
428
  * At most one switch per tick; re-entrancy guarded.
411
429
  *
412
430
  * ── Why an unanswered probe is not, by itself, a verdict ───────────────────
@@ -434,11 +452,11 @@ class AutoSenseSupervisor {
434
452
  * landing; the moment they stop landing the same probe counts, and K of them
435
453
  * demote exactly as before.
436
454
  *
437
- * It has to be `localInferences` and not the total analysis count. With
438
- * `cloudFallbackOnError` on, a frame the local provider THREW on is answered
439
- * by the cloud and still lands as an analysis — so a dead runtime would keep
440
- * advancing the total forever and this rule would mask it permanently, which
441
- * is strictly worse than the defect it replaces.
455
+ * It has to be `localInferences` and not the total analysis count, and that
456
+ * distinction must be preserved even though the connector no longer has a
457
+ * cloud path that could advance the total on its own: this counter is the
458
+ * supervisor's only proof of life, so anything but a COMPLETED on-device
459
+ * inference advancing it would make the proof self-fulfilling.
442
460
  *
443
461
  * Deliberately NOT keyed on "we have a request in flight": a serve that
444
462
  * ACCEPTS and never answers holds our permit for the provider's whole timeout,
@@ -461,14 +479,17 @@ class AutoSenseSupervisor {
461
479
  if (this.mode === 'waiting') {
462
480
  if (health.ok) {
463
481
  this.deps.log('[auto-sense] LOCAL AI is ready — starting on-device sensing');
482
+ this.lastWaitReason = null;
464
483
  await this.switchTo('local');
484
+ return;
465
485
  }
466
- return;
467
- }
468
- if (this.mode === 'cloud') {
469
- if (health.ok) {
470
- this.deps.log('[auto-sense] upgrading CLOUD->LOCAL (LiteRT-LM now ready)');
471
- await this.switchTo('local');
486
+ // Still waiting. Restate the cause every tick, and only when it CHANGES
487
+ // say so loudly — an unchanged reason is the normal shape of a long model
488
+ // import, not news.
489
+ if (health.reason !== this.lastWaitReason) {
490
+ this.deps.log(`[auto-sense] still waiting for the on-device model — ${health.reason} ` +
491
+ '(cameras are open and reporting health; nothing is being analyzed or uploaded)');
492
+ this.lastWaitReason = health.reason;
472
493
  }
473
494
  return;
474
495
  }
@@ -488,19 +509,16 @@ class AutoSenseSupervisor {
488
509
  }
489
510
  this.consecutiveUnhealthy += 1;
490
511
  if (this.consecutiveUnhealthy >= this.deps.unhealthyChecksBeforeFallback) {
491
- if (this.deps.allowCloudFallback) {
492
- // Name the reason. The no-fallback branch below always has; this one
493
- // did not, so the operator whose bill just moved onto the metered
494
- // cloud path saw a demotion with no cause attached to it.
495
- this.deps.log(`[auto-sense] falling back LOCAL->CLOUD (explicit policy; LiteRT-LM unhealthy after ` +
496
- `${this.consecutiveUnhealthy} consecutive checks) — ${health.reason}`);
497
- await this.switchTo('cloud');
498
- }
499
- else {
500
- this.deps.log(`[auto-sense] LOCAL AI health check failed — ${health.reason}. ` +
501
- 'Cloud inference remains disabled; local sensing will recover in place.');
502
- this.consecutiveUnhealthy = 0;
503
- }
512
+ // Stay on LOCAL and recover in place. Deliberately NOT a demotion to
513
+ // `waiting`: the probe has measured false negatives under exactly the
514
+ // load a healthy 16-camera connector generates (4 of 5 probes timed out
515
+ // during one 11.8 s inference), so tearing down a working sensing run on
516
+ // the strength of an unanswered ping would be the connector's own
517
+ // success switching it off. The loops keep trying and a genuinely dead
518
+ // runtime surfaces as per-frame failures, which are visible.
519
+ this.deps.log(`[auto-sense] LOCAL AI health check failed — ${health.reason}. ` +
520
+ 'There is no cloud path; on-device sensing will recover in place.');
521
+ this.consecutiveUnhealthy = 0;
504
522
  }
505
523
  }
506
524
  }
@@ -508,9 +526,9 @@ class AutoSenseSupervisor {
508
526
  async switchTo(mode) {
509
527
  if (this.mode === mode || this.switching)
510
528
  return;
511
- const next = this.getManager(mode);
529
+ const next = this.getManager();
512
530
  if (!next) {
513
- // Can't happen mid-run (both modes resolve the SAME static feeds), but
531
+ // Can't happen mid-run (it is the SAME instance over the SAME feeds), but
514
532
  // never tear down the working manager for one we can't start.
515
533
  this.deps.log(`[auto-sense] cannot switch to ${mode} sensing (no camera feeds) — staying on ${this.mode}`);
516
534
  return;
@@ -519,7 +537,7 @@ class AutoSenseSupervisor {
519
537
  try {
520
538
  if (this.active)
521
539
  await this.active.stop();
522
- await next.start();
540
+ await next.start(mode === 'waiting' ? { healthOnly: true } : undefined);
523
541
  this.active = next;
524
542
  this.mode = mode;
525
543
  this.consecutiveUnhealthy = 0;
@@ -575,8 +593,8 @@ function blockForever() {
575
593
  * genuine config error propagates to main() (prints + exits 1) — the same net
576
594
  * result as before, where the chosen run mode re-loaded the config and reported it.
577
595
  *
578
- * The explicit `local-sense` / `cloud-sense` entrypoints stay non-switching; only
579
- * `auto-sense` is supervised.
596
+ * The explicit `local-sense` entrypoint stays non-switching; only `auto-sense`
597
+ * is supervised.
580
598
  */
581
599
  async function runAutoSense(args, deps = {}) {
582
600
  const log = deps.log ?? ((m) => console.log(m));
@@ -585,6 +603,13 @@ async function runAutoSense(args, deps = {}) {
585
603
  const configIdx = args.indexOf('--config');
586
604
  const configPath = configIdx >= 0 && args[configIdx + 1] ? args[configIdx + 1] : 'trillboards.config.json';
587
605
  const config = load(configPath);
606
+ // Best-effort ONVIF LAN scan, fire-and-forget. This used to run only on
607
+ // `cloud-sense`; deleting that mode left it with zero call sites, which no type
608
+ // checker can see. `auto-sense` is what every platform runs now, so it is the
609
+ // right home. Settled before a no-feeds exit — feeds paired without an rtspUrl
610
+ // resolve to nothing sensable, and the scan is the only way the portal ever
611
+ // learns what is actually on the venue's network.
612
+ const discoveryDone = (0, startupDiscovery_1.kickStartupDiscovery)(config);
588
613
  const litertLmBaseUrl = config.localSensing?.litertLmBaseUrl || DEFAULT_LITERT_LM_BASE_URL;
589
614
  const probeTimeoutMs = config.localSensing?.healthProbeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
590
615
  const expectedModel = config.localSensing?.model || DEFAULT_LITERT_LM_MODEL;
@@ -593,7 +618,6 @@ async function runAutoSense(args, deps = {}) {
593
618
  const recheckIntervalMs = Math.max(MIN_HEALTH_RECHECK_INTERVAL_MS, config.localSensing?.healthRecheckIntervalMs ?? DEFAULT_HEALTH_RECHECK_INTERVAL_MS);
594
619
  const unhealthyChecksBeforeFallback = Math.max(1, config.localSensing?.localUnhealthyChecksBeforeFallback ??
595
620
  DEFAULT_LOCAL_UNHEALTHY_CHECKS_BEFORE_FALLBACK);
596
- const allowCloudFallback = config.localSensing?.allowCloudFallback === true;
597
621
  const probe = deps.probe
598
622
  ? () => deps.probe(litertLmBaseUrl, probeTimeoutMs, expectedModel)
599
623
  : () => probeLiteRtLmHealth(litertLmBaseUrl, { timeoutMs: probeTimeoutMs, expectedModel });
@@ -608,18 +632,22 @@ async function runAutoSense(args, deps = {}) {
608
632
  const supervisor = new AutoSenseSupervisor({
609
633
  probe,
610
634
  buildLocalManager: deps.buildLocalManager ?? (() => (0, sensingRunMode_1.buildLocalSensingManager)(config, undefined, { configPath })),
611
- buildCloudManager: deps.buildCloudManager ?? (() => (0, sensingRunMode_1.buildCloudSensingManager)(config, undefined, { configPath })),
612
635
  log,
613
636
  sleep,
614
637
  setTimer: deps.setTimer ?? ((cb, ms) => setTimeout(cb, ms)),
615
638
  clearTimer: deps.clearTimer ?? ((h) => clearTimeout(h)),
616
- onNoFeeds: deps.onNoFeeds ?? (() => process.exit(1)),
639
+ onNoFeeds: deps.onNoFeeds ??
640
+ (() => {
641
+ // Let the LAN scan finish reporting before we go. Without this the exit
642
+ // races the POST and the operator's "Find my cameras" stays empty in
643
+ // exactly the situation that produced the no-feeds exit.
644
+ void (0, startupDiscovery_1.settleBeforeExit)(discoveryDone).finally(() => process.exit(1));
645
+ }),
617
646
  onLocalActivated: startModelOtaOnce,
618
647
  startupAttempts,
619
648
  startupBackoffMs,
620
649
  recheckIntervalMs,
621
650
  unhealthyChecksBeforeFallback,
622
- allowCloudFallback,
623
651
  litertLmBaseUrl,
624
652
  expectedModel,
625
653
  probeTimeoutMs,