@trillboards/edge-sdk 0.2.13 → 0.2.15

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 (54) hide show
  1. package/deploy/windows/README.md +7 -0
  2. package/deploy/windows/build-windows.ps1 +4 -0
  3. package/deploy/windows/ensure-sensing.ps1 +58 -0
  4. package/deploy/windows/install-update.ps1 +32 -6
  5. package/deploy/windows/setup-litertlm.ps1 +45 -0
  6. package/deploy/windows/stop-existing.ps1 +51 -1
  7. package/deploy/windows/trillboards-edge.cmd +8 -2
  8. package/deploy/windows/trillboards-hidden-run.vbs +47 -0
  9. package/deploy/windows/trillboards-setup.ps1 +25 -0
  10. package/dist/AudienceAnalyzeClient.d.ts +16 -2
  11. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  12. package/dist/AudienceAnalyzeClient.js.map +1 -1
  13. package/dist/CloudSensingManager.d.ts +213 -5
  14. package/dist/CloudSensingManager.d.ts.map +1 -1
  15. package/dist/CloudSensingManager.js +432 -23
  16. package/dist/CloudSensingManager.js.map +1 -1
  17. package/dist/EdgeAgent.js +1 -1
  18. package/dist/SceneChangeDetector.d.ts +39 -3
  19. package/dist/SceneChangeDetector.d.ts.map +1 -1
  20. package/dist/SceneChangeDetector.js +84 -16
  21. package/dist/SceneChangeDetector.js.map +1 -1
  22. package/dist/VlmAdmissionGate.d.ts +14 -0
  23. package/dist/VlmAdmissionGate.d.ts.map +1 -1
  24. package/dist/VlmAdmissionGate.js +14 -0
  25. package/dist/VlmAdmissionGate.js.map +1 -1
  26. package/dist/VlmCandidateBuffer.d.ts +236 -0
  27. package/dist/VlmCandidateBuffer.d.ts.map +1 -0
  28. package/dist/VlmCandidateBuffer.js +347 -0
  29. package/dist/VlmCandidateBuffer.js.map +1 -0
  30. package/dist/config.d.ts +46 -0
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js.map +1 -1
  33. package/dist/diagnose.d.ts +1 -1
  34. package/dist/diagnose.js +1 -1
  35. package/dist/discovery.d.ts +15 -0
  36. package/dist/discovery.d.ts.map +1 -1
  37. package/dist/discovery.js +65 -0
  38. package/dist/discovery.js.map +1 -1
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +7 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/recorderRanking.d.ts +71 -0
  44. package/dist/recorderRanking.d.ts.map +1 -0
  45. package/dist/recorderRanking.js +230 -0
  46. package/dist/recorderRanking.js.map +1 -0
  47. package/dist/sensingRunMode.d.ts.map +1 -1
  48. package/dist/sensingRunMode.js +124 -0
  49. package/dist/sensingRunMode.js.map +1 -1
  50. package/dist/setupMode.d.ts +9 -0
  51. package/dist/setupMode.d.ts.map +1 -1
  52. package/dist/setupMode.js +23 -1
  53. package/dist/setupMode.js.map +1 -1
  54. package/package.json +1 -1
@@ -12,6 +12,7 @@ const runtimePaths_1 = require("./runtimePaths");
12
12
  const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
13
13
  const VlmAdmissionGate_1 = require("./VlmAdmissionGate");
14
14
  const SceneChangeDetector_1 = require("./SceneChangeDetector");
15
+ const VlmCandidateBuffer_1 = require("./VlmCandidateBuffer");
15
16
  /**
16
17
  * Thin cloud-sensing run mode.
17
18
  *
@@ -32,11 +33,11 @@ const SceneChangeDetector_1 = require("./SceneChangeDetector");
32
33
  * (`litert-lm serve`) takes one conversation at a time. So a tick is not "grab
33
34
  * a frame and send it"; it is a two-stage cascade, cheapest stage first:
34
35
  *
35
- * 1. ADMISSION (`VlmAdmissionGate`, global, one slot). Checked before the
36
- * inference capture. Denied ticks DROP inference work and re-arm at a
37
- * short retry delay. A separate, serialized decode-only probe runs at a
38
- * much slower cadence so model saturation never makes healthy video look
39
- * disconnected; that probe is not encoded, queued, or analyzed.
36
+ * 1. ADMISSION (`VlmAdmissionGate`, global, one slot). Denied ticks DROP
37
+ * inference work and re-arm at a short retry delay. A separate, serialized
38
+ * decode-only probe runs at a much slower cadence so model saturation never
39
+ * makes healthy video look disconnected; that probe is not encoded,
40
+ * queued, or analyzed.
40
41
  * 2. DECIMATION (`motionGate.scanIntervalMs`, floored at
41
42
  * `DEFAULT_MIN_MOTION_SCAN_INTERVAL_MS`). Frigate's stage 1: never LOOK
42
43
  * faster than 5 fps, because motion does not get more truthful above that
@@ -49,6 +50,36 @@ const SceneChangeDetector_1 = require("./SceneChangeDetector");
49
50
  * the same cells as the last analyzed frame is a parked car or a swinging
50
51
  * sign, and costs 1/N of a moving one.
51
52
  *
53
+ * ── Where admission sits, and the candidate buffer ──────────────────────────
54
+ * With the cascade OFF, admission is checked FIRST: nothing has classified the
55
+ * frame, so there is no reason to pay for a grab the model cannot take, and a
56
+ * denied tick drops without touching the camera. That is the original order and
57
+ * it is unchanged.
58
+ *
59
+ * With the cascade ON, the order inverts — LOOK first, admit second — and a
60
+ * candidate the gate cannot take right now is HELD in `VlmCandidateBuffer`
61
+ * rather than dropped. Both halves of that are load-bearing:
62
+ *
63
+ * - Admission-first meant a saturated connector never looked. The motion
64
+ * stage only runs on a frame we grabbed, so while the model was busy no
65
+ * feed could even form an opinion, and "the cascade already decided this
66
+ * frame is interesting" was unreachable exactly when it mattered. Measured
67
+ * in the field on 32 cameras (connector 0.2.13): ~2.4 analyses/min, so a
68
+ * given camera waits ~13 minutes for its turn — longer than any spill,
69
+ * fall or restricted-area entry lasts.
70
+ * - Holding is bounded and only for CANDIDATES. A no-change frame is never
71
+ * held; that would rebuild the undifferentiated backlog the gate refuses.
72
+ * See VlmCandidateBuffer for the three bounds, the freshness TTL, and why
73
+ * "a stale frame is worthless" is true of ambient sampling and false of
74
+ * alerting.
75
+ *
76
+ * Looking while the model is busy is affordable because looking is the cheap
77
+ * half by construction (stage 1 floors it at 5 fps per feed, and a real
78
+ * deployment runs ~150x slower than that), and because the denied path ALREADY
79
+ * pulled a decode-only health frame and threw the information away. The
80
+ * inversion also narrows what an admission slot means: it is now held for the
81
+ * inference alone, not for the grab and encode in front of it.
82
+ *
52
83
  * Every stage is independently disableable, and every drop is attributed to the
53
84
  * stage that made it in `getSensingHealth().cascade.byReason` — because the
54
85
  * honest form of degradation on a machine that cannot keep up is a number an
@@ -190,6 +221,7 @@ class CloudSensingManager extends events_1.EventEmitter {
190
221
  /** The last cadence verdict applied, surfaced by getSensingHealth(). */
191
222
  this.cadence = null;
192
223
  this.cadenceTimer = null;
224
+ this.candidatePumpTimer = null;
193
225
  /** Retained so a cadence change can rebuild each feed's gate heartbeat. */
194
226
  this.motionGateOptions = null;
195
227
  /** Look-rate for a still feed when the cascade is on. null ⇒ cascade off. */
@@ -278,6 +310,14 @@ class CloudSensingManager extends events_1.EventEmitter {
278
310
  });
279
311
  }
280
312
  }
313
+ // The hold exists only where a cascade can name a candidate. `enabled`
314
+ // can turn it OFF where it would otherwise run; it can never turn it on
315
+ // without a classifier, because that is the queue the gate refuses.
316
+ const candidateBufferEnabled = motionGateEnabled && options.candidateBuffer?.enabled !== false;
317
+ this.candidates = candidateBufferEnabled
318
+ ? new VlmCandidateBuffer_1.VlmCandidateBuffer({ ...options.candidateBuffer, now: this.now })
319
+ : null;
320
+ this.candidatePumpIntervalMs = Math.max(1, Math.trunc(options.candidateBuffer?.pumpIntervalMs ?? this.admissionRetryDelayMs));
281
321
  this.motionDrivesCadence = options.motionDrivesCadence ?? motionGateEnabled;
282
322
  this.cadenceSource = options.cadenceSource;
283
323
  this.cadenceRefreshIntervalMs = Math.max(1000, Math.trunc(options.cadenceRefreshIntervalMs ?? DEFAULT_CADENCE_REFRESH_MS));
@@ -409,6 +449,10 @@ class CloudSensingManager extends events_1.EventEmitter {
409
449
  const cloudEscalated = escalation.reduce((n, e) => n + e.escalated, 0);
410
450
  // Per-stage attribution: read each feed's gate once, then fold.
411
451
  const gateStats = this.loops.map((s) => s.motionGate?.getStats() ?? null);
452
+ // One read of the hold, so the top-level totals and the per-feed rows can
453
+ // never describe two different instants.
454
+ const candidateStats = this.candidates?.getStats() ?? null;
455
+ const candidateByFeed = new Map((candidateStats?.perFeed ?? []).map((f) => [f.name, f]));
412
456
  const cascadeByReason = emptyMotionReasonCounts();
413
457
  let cascadeEvaluated = 0;
414
458
  let cascadeAnalyzed = 0;
@@ -447,8 +491,24 @@ class CloudSensingManager extends events_1.EventEmitter {
447
491
  skipped: cascadeEvaluated - cascadeAnalyzed,
448
492
  byReason: cascadeByReason,
449
493
  },
494
+ candidateBuffer: {
495
+ enabled: this.candidates !== null,
496
+ buffered: candidateStats?.buffered ?? 0,
497
+ bytes: candidateStats?.bytes ?? 0,
498
+ maxFrames: candidateStats?.maxFrames ?? 0,
499
+ maxPerFeed: candidateStats?.maxPerFeed ?? 0,
500
+ maxBytes: candidateStats?.maxBytes ?? 0,
501
+ ttlMs: candidateStats?.ttlMs ?? 0,
502
+ enqueued: candidateStats?.enqueued ?? 0,
503
+ served: candidateStats?.served ?? 0,
504
+ evicted: candidateStats?.evicted ?? 0,
505
+ expired: candidateStats?.expired ?? 0,
506
+ oldestAgeMs: candidateStats?.oldestAgeMs ?? null,
507
+ perFeed: candidateStats?.perFeed ?? [],
508
+ },
450
509
  cloudEscalated,
451
510
  cloudEscalationEnabled: escalation.some((e) => e.enabled),
511
+ accelerator: foldAcceleratorHealth(this.loops.map((s) => acceleratorHealthFor(s)).filter((a) => a !== null)),
452
512
  perFeed: this.loops.map((s, i) => ({
453
513
  name: s.deps.name,
454
514
  captures: s.captureCount,
@@ -458,6 +518,8 @@ class CloudSensingManager extends events_1.EventEmitter {
458
518
  motionSkippedByReason: gateStats[i]
459
519
  ? { ...emptyMotionReasonCounts(), ...gateStats[i].byReason }
460
520
  : emptyMotionReasonCounts(),
521
+ candidatesBuffered: candidateByFeed.get(s.deps.name)?.buffered ?? 0,
522
+ candidatesServed: candidateByFeed.get(s.deps.name)?.served ?? 0,
461
523
  cloudEscalated: escalation[i].escalated,
462
524
  faceDetected: s.faceDetected,
463
525
  currentIntervalMs: this.intervalFor(s),
@@ -485,6 +547,11 @@ class CloudSensingManager extends events_1.EventEmitter {
485
547
  s.runMotionSkipped = 0;
486
548
  void this.startLoop(s, generation).catch((err) => this.emitLoopError(s, err));
487
549
  }
550
+ // A candidate held across a stop()/start() cycle is a frame from a run
551
+ // whose cameras may now be pointing somewhere else — the same reason the
552
+ // motion gate re-seeds above.
553
+ this.candidates?.reset();
554
+ this.scheduleCandidatePump(generation);
488
555
  // Ask once at startup so a connector picks up its deployed cadence on boot
489
556
  // rather than after a full refresh period, then keep asking. Both are
490
557
  // best-effort: refreshDeployedCadence never throws and never resets the
@@ -508,6 +575,12 @@ class CloudSensingManager extends events_1.EventEmitter {
508
575
  // closed against the NEXT run.
509
576
  this.admissionGate.reset();
510
577
  this.healthCaptureInFlight = false;
578
+ if (this.candidatePumpTimer) {
579
+ clearTimeout(this.candidatePumpTimer);
580
+ this.candidatePumpTimer = null;
581
+ }
582
+ // Held frames belong to the run that captured them.
583
+ this.candidates?.reset();
511
584
  if (this.cadenceTimer) {
512
585
  clearInterval(this.cadenceTimer);
513
586
  this.cadenceTimer = null;
@@ -622,7 +695,9 @@ class CloudSensingManager extends events_1.EventEmitter {
622
695
  }
623
696
  return;
624
697
  }
625
- this.reportCameraOpen(s, { frameProven: true, sensingState: 'idle' });
698
+ // The handshake succeeded — that is REACHABILITY, not video. The first
699
+ // real capture below is what proves frames decode.
700
+ this.reportCameraOpen(s, { streamOpened: true, sensingState: 'idle' });
626
701
  }
627
702
  catch (err) {
628
703
  if (!this.isCurrentStart(generation)) {
@@ -827,7 +902,11 @@ class CloudSensingManager extends events_1.EventEmitter {
827
902
  // frame as "changed". The heartbeat clock is deliberately NOT reset: an
828
903
  // outage must not delay the feed's next guaranteed report.
829
904
  s.motionGate?.reset();
830
- this.reportCameraOpen(s, { frameProven: true, sensingState: 'idle' });
905
+ // A re-open proves the recorder answered again — NOT that video came
906
+ // back. Claiming a frame here let a flapping feed refresh its own video
907
+ // clock forever, so a camera that reconnected every few seconds and
908
+ // decoded nothing could never age into "waiting for video".
909
+ this.reportCameraOpen(s, { streamOpened: true, sensingState: 'idle' });
831
910
  this.emit('reconnected', { name: s.deps.name });
832
911
  return true;
833
912
  }
@@ -886,14 +965,35 @@ class CloudSensingManager extends events_1.EventEmitter {
886
965
  });
887
966
  });
888
967
  }
968
+ /**
969
+ * Report this feed as `open`, carrying the proof we ACTUALLY have.
970
+ *
971
+ * `frameProven` and `streamOpened` are two different facts and the caller must
972
+ * not conflate them:
973
+ *
974
+ * `streamOpened` — `camera.open()` resolved. The RTSP handshake completed,
975
+ * so the RECORDER IS REACHABLE. No frame has decoded, so
976
+ * this deliberately carries NO `lastFrameAtMs` and does NOT
977
+ * advance `lastCaptureAtMs` — a feed that has proved only
978
+ * reachability is still owed a video probe, immediately.
979
+ * `frameProven` — ffmpeg handed us real bytes. VIDEO IS HEALTHY.
980
+ *
981
+ * Both force a report, because both are transitions an operator is waiting on
982
+ * ("Connecting…" must clear the moment the recorder answers).
983
+ */
889
984
  reportCameraOpen(s, options = {}) {
890
985
  const sensingState = options.sensingState ?? s.lastSensingState ?? 'idle';
891
986
  const now = this.now();
892
987
  const stateChanged = s.lastSensingState !== sensingState;
893
988
  const heartbeatDue = s.lastHealthReportAtMs == null ||
894
989
  now - s.lastHealthReportAtMs >= this.cameraHealthHeartbeatIntervalMs;
895
- if (!options.frameProven && !options.force && !stateChanged && !heartbeatDue)
990
+ if (!options.frameProven &&
991
+ !options.streamOpened &&
992
+ !options.force &&
993
+ !stateChanged &&
994
+ !heartbeatDue) {
896
995
  return;
996
+ }
897
997
  if (options.frameProven)
898
998
  s.lastCaptureAtMs = now;
899
999
  this.reportCameraHealth(s, {
@@ -902,7 +1002,9 @@ class CloudSensingManager extends events_1.EventEmitter {
902
1002
  sensingState,
903
1003
  ...(options.frameProven
904
1004
  ? { lastFrameAtMs: now, captureProof: 'decoded_frame' }
905
- : {}),
1005
+ : options.streamOpened
1006
+ ? { captureProof: 'stream_open' }
1007
+ : {}),
906
1008
  });
907
1009
  }
908
1010
  healthProbeDue(s) {
@@ -934,17 +1036,127 @@ class CloudSensingManager extends events_1.EventEmitter {
934
1036
  }
935
1037
  }
936
1038
  /**
937
- * One cascade pass for a feed: admission → capture → motion → inference,
938
- * with a bounded decode-only health probe when admission is denied.
1039
+ * One cascade pass for a feed. Which pass depends on whether there is a
1040
+ * cascade to name a candidate — see the class header for why the order
1041
+ * differs and why that is not two behaviours bolted together.
939
1042
  *
940
- * The admission check comes first so denied inference frames are dropped,
941
- * never buffered. Video health is independent: at most one feed at a time
942
- * may take a slow-cadence decode-only proof while the model is occupied.
1043
+ * Deliberately NOT `async`: it hands the chosen pass's promise straight back
1044
+ * instead of adopting it. An `async` dispatcher would insert two microtask
1045
+ * hops in front of every capture on a path this PR does not otherwise
1046
+ * change, and a fake-timer test that advances a fixed number of drains would
1047
+ * then complete fewer ticks per advance — which is not a behaviour change but
1048
+ * is indistinguishable from one at 2 a.m. Zero delta on the untouched path is
1049
+ * cheaper to hold than the argument about whether the delta mattered.
943
1050
  */
944
- async captureAndPost(s) {
1051
+ captureAndPost(s) {
945
1052
  if (!s.deps.camera.isOpen()) {
946
- throw new Error(`CloudSensingManager: camera "${s.deps.name}" is not open`);
1053
+ return Promise.reject(new Error(`CloudSensingManager: camera "${s.deps.name}" is not open`));
1054
+ }
1055
+ if (this.candidates && s.motionGate) {
1056
+ return this.lookThenAdmit(s, this.candidates, s.motionGate);
1057
+ }
1058
+ return this.admitThenLook(s);
1059
+ }
1060
+ /**
1061
+ * LOOK FIRST, admit second: capture → motion → (candidate) → admission.
1062
+ *
1063
+ * Runs only with the cascade on, because only the cascade can answer "is
1064
+ * this frame worth holding?". A candidate the gate cannot take right now
1065
+ * goes into the bounded hold instead of onto the floor; anything the cascade
1066
+ * rejected is dropped exactly as before and is never offered.
1067
+ */
1068
+ async lookThenAdmit(s, candidates, motionGate) {
1069
+ const frame = await s.deps.camera.getFrame();
1070
+ const res = safeGetResolution(s.deps.camera);
1071
+ const jpeg = await this.toJpeg(frame, res?.width, res?.height);
1072
+ s.captureCount++;
1073
+ this.reportCameraOpen(s, { frameProven: true, sensingState: 'analyzing', force: true });
1074
+ this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
1075
+ const verdict = await motionGate.evaluate(jpeg);
1076
+ if (this.motionDrivesCadence) {
1077
+ this.applyMotionCadence(s, verdict.motion);
1078
+ }
1079
+ if (!verdict.analyze) {
1080
+ s.motionSkipCount++;
1081
+ s.runMotionSkipped++;
1082
+ this.emit('motionSkipped', {
1083
+ name: s.deps.name,
1084
+ reason: verdict.reason,
1085
+ pctMotion: verdict.pctMotion,
1086
+ regions: verdict.boxes.length,
1087
+ });
1088
+ this.reportCameraOpen(s, { sensingState: 'idle' });
1089
+ return null;
1090
+ }
1091
+ // The cascade says this frame is worth an inference. Everything below is
1092
+ // about WHEN it gets one, never about whether it survives.
1093
+ const captureMode = s.faceDetected ? 'face_triggered' : 'periodic';
1094
+ const ticket = this.admissionGate.tryAcquire(s.deps.name);
1095
+ if (!ticket) {
1096
+ s.admissionDeniedCount++;
1097
+ s.runAdmissionDenied++;
1098
+ // Only a CHANGE is held. A still-scene heartbeat or an undecodable frame
1099
+ // is a sample, and for a sample the admission gate's original rule is
1100
+ // exactly right: the newest one is the best one, so holding an old one
1101
+ // would spend a slot a real event could have had. Measured at 32 cameras
1102
+ // on the CPU profile, that distinction is not cosmetic — starvation makes
1103
+ // almost every feed permanently heartbeat-due, so a hold that took every
1104
+ // heartbeat filled with proof-of-life frames and crowded the arrivals out
1105
+ // of their own buffer.
1106
+ //
1107
+ // `isChangeCandidate` reads the whole verdict and not its label, because
1108
+ // that same starvation is what makes a REAL arrival arrive labelled
1109
+ // `heartbeat` — the gate resolves heartbeat-due before it looks at the
1110
+ // motion verdict, so the change survives only in `verdict.motion`.
1111
+ const buffered = (0, SceneChangeDetector_1.isChangeCandidate)(verdict) &&
1112
+ candidates.offer({
1113
+ name: s.deps.name,
1114
+ jpeg,
1115
+ captureMode,
1116
+ reason: verdict.reason,
1117
+ signature: verdict.signature,
1118
+ }) === 'buffered';
1119
+ this.emit('admissionDenied', {
1120
+ name: s.deps.name,
1121
+ // No short retry either way. Unlike the no-cascade path, this tick has
1122
+ // already LOOKED: a held frame is safe until the pump takes it, and a
1123
+ // frame we chose not to hold is one whose successor is strictly
1124
+ // better — so the right next move is the normal scan cadence, not a
1125
+ // 250 ms re-grab of the same scene on all 32 feeds at once.
1126
+ retryInMs: null,
1127
+ buffered,
1128
+ reason: verdict.reason,
1129
+ inFlight: this.admissionGate.getInFlight(),
1130
+ maxInFlight: this.admissionGate.getMaxInFlight(),
1131
+ });
1132
+ this.reportCameraOpen(s, { sensingState: 'waiting_for_capacity' });
1133
+ return null;
1134
+ }
1135
+ try {
1136
+ let response;
1137
+ try {
1138
+ response = await s.deps.client.analyze({ jpeg, captureMode });
1139
+ }
1140
+ catch (err) {
1141
+ this.reportCameraOpen(s, { sensingState: 'unavailable' });
1142
+ throw err;
1143
+ }
1144
+ this.recordAnalysis(s, response, verdict.signature);
1145
+ return response;
1146
+ }
1147
+ finally {
1148
+ this.admissionGate.release(ticket);
947
1149
  }
1150
+ }
1151
+ /**
1152
+ * ADMIT FIRST, look second — the original order, unchanged, and the only one
1153
+ * available without a cascade: nothing has classified the frame, so paying
1154
+ * for a grab the model cannot take buys nothing, and a denied tick drops
1155
+ * without touching the camera. Video health stays independent: at most one
1156
+ * feed at a time may take a slow-cadence decode-only proof while the model
1157
+ * is occupied.
1158
+ */
1159
+ async admitThenLook(s) {
948
1160
  const ticket = this.admissionGate.tryAcquire(s.deps.name);
949
1161
  if (!ticket) {
950
1162
  s.admissionDeniedCount++;
@@ -980,8 +1192,10 @@ class CloudSensingManager extends events_1.EventEmitter {
980
1192
  this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
981
1193
  // Cheap stage: is this frame worth the expensive one? Releasing the slot
982
1194
  // here (via the finally) costs the backend nothing but one grab.
1195
+ let signature = null;
983
1196
  if (s.motionGate) {
984
1197
  const verdict = await s.motionGate.evaluate(jpeg);
1198
+ signature = verdict.signature;
985
1199
  if (this.motionDrivesCadence) {
986
1200
  this.applyMotionCadence(s, verdict.motion);
987
1201
  }
@@ -1017,19 +1231,148 @@ class CloudSensingManager extends events_1.EventEmitter {
1017
1231
  this.reportCameraOpen(s, { sensingState: 'unavailable' });
1018
1232
  throw err;
1019
1233
  }
1020
- s.postCount++;
1021
- s.runAnalyses++;
1022
- // Stamp the heartbeat only once the call actually landed, so a frame lost
1023
- // to the network cannot silence a feed for a whole heartbeat window.
1024
- s.motionGate?.markAnalyzed();
1025
- this.reportCameraOpen(s, { sensingState: 'active' });
1026
- this.emit('analyzed', { name: s.deps.name, response });
1234
+ this.recordAnalysis(s, response, signature);
1027
1235
  return response;
1028
1236
  }
1029
1237
  finally {
1030
1238
  this.admissionGate.release(ticket);
1031
1239
  }
1032
1240
  }
1241
+ /**
1242
+ * Everything that happens once a frame has been analyzed, in ONE place, so a
1243
+ * held frame and a fresh one can never be counted — or reported to the portal
1244
+ * — differently.
1245
+ *
1246
+ * Synchronous on purpose. The obvious factoring wraps the `analyze()` call
1247
+ * itself, but that turns every caller's inline await into an await of an
1248
+ * async helper, which adds microtask hops to the pre-existing capture path
1249
+ * for no behavioural gain. What the two callers genuinely share is the
1250
+ * bookkeeping AFTER the call, and that is what lives here; the four-line
1251
+ * try/catch around the call stays at each site, where its `unavailable`
1252
+ * report is visible next to the thing that can fail.
1253
+ */
1254
+ recordAnalysis(s, response, signature) {
1255
+ s.postCount++;
1256
+ s.runAnalyses++;
1257
+ // Stamp the heartbeat AND the stationary baseline only once the call
1258
+ // actually landed, so a frame lost to the network, to an eviction or to the
1259
+ // freshness bound cannot silence a feed for a heartbeat window or suppress
1260
+ // the rest of its event as `stationary`.
1261
+ s.motionGate?.markAnalyzed(undefined, signature);
1262
+ this.reportCameraOpen(s, { sensingState: 'active' });
1263
+ this.emit('analyzed', { name: s.deps.name, response });
1264
+ }
1265
+ /**
1266
+ * Arm the candidate pump for ONE run of the manager.
1267
+ *
1268
+ * A manager-scoped chain rather than a per-feed one, and that is the point:
1269
+ * service order across feeds has to be a DECISION (least-recently-served,
1270
+ * see VlmCandidateBuffer), not a race between 32 independent timers all
1271
+ * polling for the same freed slot. `generation` carries the same meaning it
1272
+ * carries in `scheduleNext` — a pump that survives a stop()/start() cycle
1273
+ * must not arm a second chain.
1274
+ */
1275
+ scheduleCandidatePump(generation) {
1276
+ if (!this.candidates || !this.isCurrentStart(generation))
1277
+ return;
1278
+ this.candidatePumpTimer = setTimeout(() => {
1279
+ if (!this.isCurrentStart(generation))
1280
+ return;
1281
+ try {
1282
+ this.pumpCandidates(generation);
1283
+ }
1284
+ finally {
1285
+ this.scheduleCandidatePump(generation);
1286
+ }
1287
+ }, clampTimerDelay(this.candidatePumpIntervalMs));
1288
+ // Never hold the process open for a poll that may have nothing to do.
1289
+ if (typeof this.candidatePumpTimer.unref === 'function')
1290
+ this.candidatePumpTimer.unref();
1291
+ }
1292
+ /**
1293
+ * Offer the hold to every slot that is free right now.
1294
+ *
1295
+ * Synchronous by construction: each admitted candidate is launched as its own
1296
+ * task and the pump does not await it, so one slow inference cannot delay the
1297
+ * next pump tick or another feed's turn. Concurrency stays bounded by the
1298
+ * admission gate, which is the only thing that ever knew the backend's real
1299
+ * capacity.
1300
+ */
1301
+ pumpCandidates(generation) {
1302
+ const buffer = this.candidates;
1303
+ if (!buffer)
1304
+ return;
1305
+ // Age out first: a candidate past its TTL must never win a slot from one
1306
+ // that is still current.
1307
+ buffer.expire();
1308
+ // Bounded by the slot count — this loop can hand out capacity, never spin.
1309
+ for (let guard = this.admissionGate.getMaxInFlight(); guard > 0; guard--) {
1310
+ if (buffer.size() === 0)
1311
+ return;
1312
+ if (this.admissionGate.getInFlight() >= this.admissionGate.getMaxInFlight())
1313
+ return;
1314
+ const next = buffer.peek();
1315
+ if (!next)
1316
+ return;
1317
+ const s = this.loops.find((l) => l.deps.name === next.name);
1318
+ if (!s) {
1319
+ // Unreachable by construction: `loops` is fixed at construction and a
1320
+ // candidate is only ever offered under its own loop's name. If it ever
1321
+ // does happen, the frame cannot be analyzed and leaving it would block
1322
+ // the head of the rotation forever — so drop it, but LOUDLY. The drop
1323
+ // lands in the buffer's `served` counter, which would be a lie, and a
1324
+ // quiet lie in the accounting is the exact defect this buffer exists to
1325
+ // remove; the event is what makes it findable.
1326
+ buffer.take();
1327
+ this.emit('error', {
1328
+ name: next.name,
1329
+ error: new Error(`CloudSensingManager: held candidate for unknown feed "${next.name}" — dropped`),
1330
+ });
1331
+ continue;
1332
+ }
1333
+ const ticket = this.admissionGate.tryAcquire(next.name);
1334
+ if (!ticket)
1335
+ return; // lost the slot to a live tick — try again next pump
1336
+ // Nothing above awaited, so this is the candidate `peek()` named.
1337
+ const candidate = buffer.take();
1338
+ if (!candidate) {
1339
+ this.admissionGate.release(ticket);
1340
+ return;
1341
+ }
1342
+ void this.serveCandidate(s, candidate, ticket, generation);
1343
+ }
1344
+ }
1345
+ /** Analyze one held candidate and give the slot back. Never throws. */
1346
+ async serveCandidate(s, candidate, ticket, generation) {
1347
+ const waitedMs = Math.max(0, this.now() - candidate.enqueuedAtMs);
1348
+ try {
1349
+ let response;
1350
+ try {
1351
+ response = await s.deps.client.analyze({
1352
+ jpeg: candidate.jpeg,
1353
+ captureMode: candidate.captureMode,
1354
+ });
1355
+ }
1356
+ catch (err) {
1357
+ this.reportCameraOpen(s, { sensingState: 'unavailable' });
1358
+ throw err;
1359
+ }
1360
+ this.recordAnalysis(s, response, candidate.signature);
1361
+ if (this.isCurrentStart(generation)) {
1362
+ this.emit('candidateAnalyzed', {
1363
+ name: s.deps.name,
1364
+ waitedMs,
1365
+ reason: candidate.reason,
1366
+ });
1367
+ }
1368
+ }
1369
+ catch (err) {
1370
+ this.emitLoopError(s, err);
1371
+ }
1372
+ finally {
1373
+ this.admissionGate.release(ticket);
1374
+ }
1375
+ }
1033
1376
  /**
1034
1377
  * Convert a camera frame to JPEG bytes for the POST.
1035
1378
  *
@@ -1105,6 +1448,72 @@ function localInferencesFor(s) {
1105
1448
  }
1106
1449
  return 0;
1107
1450
  }
1451
+ /**
1452
+ * Read a feed's on-device accelerator verdict defensively. The cloud client
1453
+ * does not implement `getAcceleratorHealth` (it runs no model), and a test
1454
+ * double may not either, so an absent method reads as "this feed has nothing
1455
+ * to say about a delegate" — never as an error on the health path.
1456
+ */
1457
+ function acceleratorHealthFor(s) {
1458
+ try {
1459
+ return s.deps.client.getAcceleratorHealth?.() ?? null;
1460
+ }
1461
+ catch {
1462
+ // health must never throw
1463
+ return null;
1464
+ }
1465
+ }
1466
+ /**
1467
+ * Fold N per-feed accelerator verdicts into the one an operator reads.
1468
+ *
1469
+ * PESSIMISTIC BY CONSTRUCTION. A majority vote here would do the exact thing
1470
+ * this surface exists to stop: hide a slow feed behind fast ones. So a split
1471
+ * delegate is reported as `'mixed'` rather than resolved, any demotion makes
1472
+ * the whole verdict demoted, and the reason preferentially comes from a feed
1473
+ * that actually has a problem rather than from feed 0.
1474
+ */
1475
+ function foldAcceleratorHealth(reports) {
1476
+ if (reports.length === 0) {
1477
+ return {
1478
+ feeds: 0,
1479
+ policy: null,
1480
+ delegate: null,
1481
+ gpuAdvertised: null,
1482
+ gpuDemoted: false,
1483
+ reason: null,
1484
+ models: [],
1485
+ };
1486
+ }
1487
+ const policies = new Set(reports.map((r) => r.accelerator));
1488
+ // A delegate is only settled when EVERY feed has settled on the same one. A
1489
+ // feed that has not resolved yet cannot be counted as agreeing with the rest.
1490
+ const delegates = new Set(reports.map((r) => r.selectedDelegate));
1491
+ const delegate = delegates.size > 1
1492
+ ? 'mixed'
1493
+ : (reports[0].selectedDelegate ?? null);
1494
+ // The variant either exists on that serve or it does not: one feed seeing it
1495
+ // settles the question affirmatively. Only when nobody probed is it unknown.
1496
+ const gpuAdvertised = reports.some((r) => r.gpuAdvertised === true)
1497
+ ? true
1498
+ : reports.some((r) => r.gpuAdvertised === false)
1499
+ ? false
1500
+ : null;
1501
+ // Prefer the reason from a DEMOTED feed (a fault worth acting on), then any
1502
+ // feed that is not on the GPU, then whatever is left.
1503
+ const reason = reports.find((r) => r.gpuDemoted && r.reason)?.reason ??
1504
+ reports.find((r) => r.selectedDelegate !== 'gpu' && r.reason)?.reason ??
1505
+ reports.find((r) => r.reason)?.reason ??
1506
+ null;
1507
+ return {
1508
+ feeds: reports.length,
1509
+ policy: policies.size === 1 ? reports[0].accelerator : null,
1510
+ delegate,
1511
+ gpuAdvertised,
1512
+ gpuDemoted: reports.some((r) => r.gpuDemoted),
1513
+ reason,
1514
+ models: [...new Set(reports.map((r) => r.selectedModel))].sort(),
1515
+ };
1516
+ }
1108
1517
  function escalationStatsFor(s) {
1109
1518
  try {
1110
1519
  const stats = s.deps.client.getEscalationStats?.();