@trillboards/edge-sdk 0.2.3 → 0.2.6

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 (200) hide show
  1. package/README.md +4 -3
  2. package/deploy/docker/Dockerfile.cloud-sense +117 -0
  3. package/deploy/docker/Dockerfile.cpu +30 -21
  4. package/deploy/docker/Dockerfile.cuda +30 -21
  5. package/deploy/docker/Dockerfile.openvino +30 -20
  6. package/deploy/docker/README.md +9 -4
  7. package/deploy/docker/connector-entrypoint.sh +122 -0
  8. package/deploy/helm/README.md +1 -1
  9. package/deploy/k8s/daemonset.yaml +1 -1
  10. package/deploy/macos/README.md +161 -0
  11. package/deploy/macos/TrillboardsSetupApp.m +603 -0
  12. package/deploy/macos/build-macos-pkg.sh +588 -0
  13. package/deploy/macos/com.trillboards.edge.plist +62 -0
  14. package/deploy/macos/com.trillboards.litertlm.plist +45 -0
  15. package/deploy/macos/pair.command +519 -0
  16. package/deploy/macos/scripts/ensure-litertlm.sh +62 -0
  17. package/deploy/macos/scripts/postinstall +153 -0
  18. package/deploy/macos/scripts/setup-litertlm.sh +272 -0
  19. package/deploy/macos/test-install-pair.sh +383 -0
  20. package/deploy/macos/test-setup-litertlm-reinstall.sh +109 -0
  21. package/deploy/systemd/install.sh +25 -15
  22. package/deploy/systemd/trillboards-edge.service +4 -1
  23. package/deploy/windows/README.md +70 -0
  24. package/deploy/windows/build-windows.ps1 +225 -0
  25. package/deploy/windows/bundle-windows-installer.ps1 +153 -0
  26. package/deploy/windows/check-feeds.js +26 -0
  27. package/deploy/windows/e2e/fixtures/person.jpg +0 -0
  28. package/deploy/windows/e2e/test-local-sense.ps1 +185 -0
  29. package/deploy/windows/install-update.ps1 +101 -0
  30. package/deploy/windows/installer.nsi +138 -0
  31. package/deploy/windows/register-litertlm-task.ps1 +42 -0
  32. package/deploy/windows/register-task.ps1 +44 -0
  33. package/deploy/windows/run-connector.ps1 +69 -0
  34. package/deploy/windows/setup-litertlm.ps1 +361 -0
  35. package/deploy/windows/stop-existing.ps1 +72 -0
  36. package/deploy/windows/trillboards-edge-task.vbs +25 -0
  37. package/deploy/windows/trillboards-edge.cmd +33 -0
  38. package/deploy/windows/trillboards-pair.cmd +276 -0
  39. package/deploy/windows/trillboards-setup.ps1 +456 -0
  40. package/deploy/windows/trillboards-setup.vbs +13 -0
  41. package/dist/AudienceAnalyzeClient.d.ts +136 -0
  42. package/dist/AudienceAnalyzeClient.d.ts.map +1 -0
  43. package/dist/AudienceAnalyzeClient.js +246 -0
  44. package/dist/AudienceAnalyzeClient.js.map +1 -0
  45. package/dist/ChannelProber.d.ts +150 -0
  46. package/dist/ChannelProber.d.ts.map +1 -0
  47. package/dist/ChannelProber.js +887 -0
  48. package/dist/ChannelProber.js.map +1 -0
  49. package/dist/CloudEscalationPolicy.d.ts +143 -0
  50. package/dist/CloudEscalationPolicy.d.ts.map +1 -0
  51. package/dist/CloudEscalationPolicy.js +137 -0
  52. package/dist/CloudEscalationPolicy.js.map +1 -0
  53. package/dist/CloudSensingManager.d.ts +533 -0
  54. package/dist/CloudSensingManager.d.ts.map +1 -0
  55. package/dist/CloudSensingManager.js +1254 -0
  56. package/dist/CloudSensingManager.js.map +1 -0
  57. package/dist/DiscoveryReportClient.d.ts +66 -0
  58. package/dist/DiscoveryReportClient.d.ts.map +1 -0
  59. package/dist/DiscoveryReportClient.js +85 -0
  60. package/dist/DiscoveryReportClient.js.map +1 -0
  61. package/dist/EdgeAgent.d.ts +12 -2
  62. package/dist/EdgeAgent.d.ts.map +1 -1
  63. package/dist/EdgeAgent.js +122 -65
  64. package/dist/EdgeAgent.js.map +1 -1
  65. package/dist/LiteRtVlmProvider.d.ts +193 -0
  66. package/dist/LiteRtVlmProvider.d.ts.map +1 -0
  67. package/dist/LiteRtVlmProvider.js +436 -0
  68. package/dist/LiteRtVlmProvider.js.map +1 -0
  69. package/dist/MosaicSource.d.ts +108 -0
  70. package/dist/MosaicSource.d.ts.map +1 -0
  71. package/dist/MosaicSource.js +274 -0
  72. package/dist/MosaicSource.js.map +1 -0
  73. package/dist/OnvifClient.d.ts +83 -0
  74. package/dist/OnvifClient.d.ts.map +1 -0
  75. package/dist/OnvifClient.js +209 -0
  76. package/dist/OnvifClient.js.map +1 -0
  77. package/dist/PairClient.d.ts +106 -0
  78. package/dist/PairClient.d.ts.map +1 -0
  79. package/dist/PairClient.js +186 -0
  80. package/dist/PairClient.js.map +1 -0
  81. package/dist/RecorderAddressRecovery.d.ts +53 -0
  82. package/dist/RecorderAddressRecovery.d.ts.map +1 -0
  83. package/dist/RecorderAddressRecovery.js +359 -0
  84. package/dist/RecorderAddressRecovery.js.map +1 -0
  85. package/dist/SceneChangeDetector.d.ts +291 -0
  86. package/dist/SceneChangeDetector.d.ts.map +1 -0
  87. package/dist/SceneChangeDetector.js +682 -0
  88. package/dist/SceneChangeDetector.js.map +1 -0
  89. package/dist/SensingProfileClient.d.ts +59 -0
  90. package/dist/SensingProfileClient.d.ts.map +1 -0
  91. package/dist/SensingProfileClient.js +78 -0
  92. package/dist/SensingProfileClient.js.map +1 -0
  93. package/dist/SetupSessionClient.d.ts +114 -0
  94. package/dist/SetupSessionClient.d.ts.map +1 -0
  95. package/dist/SetupSessionClient.js +95 -0
  96. package/dist/SetupSessionClient.js.map +1 -0
  97. package/dist/VlmAdmissionGate.d.ts +126 -0
  98. package/dist/VlmAdmissionGate.d.ts.map +1 -0
  99. package/dist/VlmAdmissionGate.js +166 -0
  100. package/dist/VlmAdmissionGate.js.map +1 -0
  101. package/dist/autoSense.d.ts +216 -0
  102. package/dist/autoSense.d.ts.map +1 -0
  103. package/dist/autoSense.js +521 -0
  104. package/dist/autoSense.js.map +1 -0
  105. package/dist/cli.d.ts +27 -1
  106. package/dist/cli.d.ts.map +1 -1
  107. package/dist/cli.js +511 -21
  108. package/dist/cli.js.map +1 -1
  109. package/dist/combinedSense.d.ts +128 -0
  110. package/dist/combinedSense.d.ts.map +1 -0
  111. package/dist/combinedSense.js +261 -0
  112. package/dist/combinedSense.js.map +1 -0
  113. package/dist/config.d.ts +351 -0
  114. package/dist/config.d.ts.map +1 -1
  115. package/dist/config.js +99 -2
  116. package/dist/config.js.map +1 -1
  117. package/dist/connectorCadence.d.ts +135 -0
  118. package/dist/connectorCadence.d.ts.map +1 -0
  119. package/dist/connectorCadence.js +151 -0
  120. package/dist/connectorCadence.js.map +1 -0
  121. package/dist/dataDir.d.ts +33 -0
  122. package/dist/dataDir.d.ts.map +1 -0
  123. package/dist/dataDir.js +76 -0
  124. package/dist/dataDir.js.map +1 -0
  125. package/dist/deployedCadence.d.ts +167 -0
  126. package/dist/deployedCadence.d.ts.map +1 -0
  127. package/dist/deployedCadence.js +209 -0
  128. package/dist/deployedCadence.js.map +1 -0
  129. package/dist/deployedCadenceSource.d.ts +48 -0
  130. package/dist/deployedCadenceSource.d.ts.map +1 -0
  131. package/dist/deployedCadenceSource.js +74 -0
  132. package/dist/deployedCadenceSource.js.map +1 -0
  133. package/dist/diagnose.d.ts +1 -1
  134. package/dist/diagnose.d.ts.map +1 -1
  135. package/dist/diagnose.js +13 -12
  136. package/dist/diagnose.js.map +1 -1
  137. package/dist/discovery.d.ts +131 -0
  138. package/dist/discovery.d.ts.map +1 -0
  139. package/dist/discovery.js +499 -0
  140. package/dist/discovery.js.map +1 -0
  141. package/dist/gemmaModelOta.d.ts +118 -0
  142. package/dist/gemmaModelOta.d.ts.map +1 -0
  143. package/dist/gemmaModelOta.js +291 -0
  144. package/dist/gemmaModelOta.js.map +1 -0
  145. package/dist/index.d.ts +16 -1
  146. package/dist/index.d.ts.map +1 -1
  147. package/dist/index.js +40 -1
  148. package/dist/index.js.map +1 -1
  149. package/dist/init.d.ts.map +1 -1
  150. package/dist/init.js +41 -18
  151. package/dist/init.js.map +1 -1
  152. package/dist/keepAwake.d.ts +33 -0
  153. package/dist/keepAwake.d.ts.map +1 -0
  154. package/dist/keepAwake.js +54 -0
  155. package/dist/keepAwake.js.map +1 -0
  156. package/dist/legacyRecorderProfile.d.ts +20 -0
  157. package/dist/legacyRecorderProfile.d.ts.map +1 -0
  158. package/dist/legacyRecorderProfile.js +27 -0
  159. package/dist/legacyRecorderProfile.js.map +1 -0
  160. package/dist/localInferenceSchema.d.ts +132 -0
  161. package/dist/localInferenceSchema.d.ts.map +1 -0
  162. package/dist/localInferenceSchema.js +274 -0
  163. package/dist/localInferenceSchema.js.map +1 -0
  164. package/dist/localSenseE2eHarness.d.ts +101 -0
  165. package/dist/localSenseE2eHarness.d.ts.map +1 -0
  166. package/dist/localSenseE2eHarness.js +279 -0
  167. package/dist/localSenseE2eHarness.js.map +1 -0
  168. package/dist/localSensing.d.ts +268 -0
  169. package/dist/localSensing.d.ts.map +1 -0
  170. package/dist/localSensing.js +492 -0
  171. package/dist/localSensing.js.map +1 -0
  172. package/dist/modelIntegrity.d.ts +59 -0
  173. package/dist/modelIntegrity.d.ts.map +1 -0
  174. package/dist/modelIntegrity.js +165 -0
  175. package/dist/modelIntegrity.js.map +1 -0
  176. package/dist/pairVerify.d.ts +100 -0
  177. package/dist/pairVerify.d.ts.map +1 -0
  178. package/dist/pairVerify.js +296 -0
  179. package/dist/pairVerify.js.map +1 -0
  180. package/dist/qrScanner.d.ts +33 -0
  181. package/dist/qrScanner.d.ts.map +1 -0
  182. package/dist/qrScanner.js +103 -0
  183. package/dist/qrScanner.js.map +1 -0
  184. package/dist/runtimePaths.d.ts +10 -0
  185. package/dist/runtimePaths.d.ts.map +1 -0
  186. package/dist/runtimePaths.js +79 -0
  187. package/dist/runtimePaths.js.map +1 -0
  188. package/dist/sensingRunMode.d.ts +68 -0
  189. package/dist/sensingRunMode.d.ts.map +1 -0
  190. package/dist/sensingRunMode.js +346 -0
  191. package/dist/sensingRunMode.js.map +1 -0
  192. package/dist/setupMode.d.ts +98 -0
  193. package/dist/setupMode.d.ts.map +1 -0
  194. package/dist/setupMode.js +828 -0
  195. package/dist/setupMode.js.map +1 -0
  196. package/dist/setupProgress.d.ts +31 -0
  197. package/dist/setupProgress.d.ts.map +1 -0
  198. package/dist/setupProgress.js +173 -0
  199. package/dist/setupProgress.js.map +1 -0
  200. package/package.json +13 -4
@@ -0,0 +1,1254 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CloudSensingManager = void 0;
4
+ exports.classifyRtspFailureClass = classifyRtspFailureClass;
5
+ exports.buildSensingLoops = buildSensingLoops;
6
+ exports.buildTiledCameras = buildTiledCameras;
7
+ exports.resolveFeeds = resolveFeeds;
8
+ const events_1 = require("events");
9
+ const AudienceAnalyzeClient_1 = require("./AudienceAnalyzeClient");
10
+ const MosaicSource_1 = require("./MosaicSource");
11
+ const runtimePaths_1 = require("./runtimePaths");
12
+ const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
13
+ const VlmAdmissionGate_1 = require("./VlmAdmissionGate");
14
+ const SceneChangeDetector_1 = require("./SceneChangeDetector");
15
+ /**
16
+ * Thin cloud-sensing run mode.
17
+ *
18
+ * RTSP frame → (optional cheap motion/face gate) → AudienceAnalyzeClient POST.
19
+ * NO local ONNX — the cloud VLM does the inference. This is the agent's
20
+ * "point any CCTV at Trillboards" core: one connector process runs N capture
21
+ * loops (one per feed), each POSTing under ITS OWN device token so the cloud
22
+ * resolves THAT screen's profile.
23
+ *
24
+ * The cadence loop is ported from `@trillboards/edge-cloud`'s
25
+ * FrameCaptureManager: capture every `periodicIntervalMs` (5 min default) when
26
+ * NO faces, every `faceTriggeredIntervalMs` (30 s) when faces ARE present; POST
27
+ * only when faceDetected OR on the periodic cadence (cost control). Edge motion
28
+ * gating drives `setFaceDetected(...)`.
29
+ *
30
+ * ── The 16-camera cascade ───────────────────────────────────────────────────
31
+ * N feeds share ONE inference backend, and in local-sensing mode that backend
32
+ * (`litert-lm serve`) takes one conversation at a time. So a tick is not "grab
33
+ * a frame and send it"; it is a two-stage cascade, cheapest stage first:
34
+ *
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.
40
+ * 2. MOTION (`MotionGate`, per feed). Runs on a ~178x100 greyscale frame and
41
+ * answers "did anything change?" in single-digit milliseconds. A frame
42
+ * that fails it releases the admission slot immediately — an empty aisle
43
+ * costs one cheap grab instead of a 6–32 s inference.
44
+ *
45
+ * Both stages are counted per feed (`getSensingHealth()`), because the honest
46
+ * form of degradation on a machine that cannot keep up is a number an operator
47
+ * can read — "sampling every 104 s per camera; 9.5 analyses/min sustained" —
48
+ * not silent frame loss.
49
+ */
50
+ const DEFAULT_PERIODIC_MS = 300000; // 5 min
51
+ const DEFAULT_FACE_MS = 30000; // 30 s
52
+ const DEFAULT_JPEG_QUALITY = 70;
53
+ // Avoid a multi-camera stampede against consumer NVRs: on startup/reconnect,
54
+ // phase capture loops apart by feed index. One-camera installs keep zero offset.
55
+ //
56
+ // `feedStaggerMs` is the DESIRED step and `maxFeedStaggerMs` the total spread
57
+ // WINDOW — the step shrinks to fit the window when there are many feeds, so
58
+ // every feed keeps a DISTINCT phase. The previous shape clamped each feed's
59
+ // offset at the window instead, which made feed 15, 16, 17 … share one offset:
60
+ // anti-stampede protection failed exactly at the 16-camera target it existed
61
+ // for. The window is additionally capped by the fastest cadence so the spread
62
+ // can never exceed one capture period.
63
+ const DEFAULT_FEED_STAGGER_MS = 1000;
64
+ const DEFAULT_MAX_FEED_STAGGER_MS = 15000;
65
+ // Re-attempt bounds for a tick the admission gate denied. Derived from the
66
+ // stagger step (so 16 feeds poll a freed slot ~2 s apart, not all at once).
67
+ const MIN_ADMISSION_RETRY_MS = 250;
68
+ const MAX_ADMISSION_RETRY_MS = 5000;
69
+ // RTSP self-recovery backoff: a feed/network blip must NEVER permanently kill
70
+ // sensing on an unattended 24/7 connector. When a camera drops (the RTSP
71
+ // provider gives up its per-attempt retries and closes itself), the loop
72
+ // re-opens it with bounded exponential backoff — starting here, doubling each
73
+ // failure, clamped at the cap — and retries FOREVER (the camera may come back at
74
+ // any time). The backoff resets the instant a re-open succeeds.
75
+ const DEFAULT_RECONNECT_INITIAL_MS = 1000; // 1 s
76
+ const DEFAULT_RECONNECT_MAX_MS = 30000; // 30 s
77
+ const DEFAULT_CAMERA_HEALTH_FAILURE_GRACE_COUNT = 3;
78
+ const DEFAULT_CAMERA_HEALTH_PROBE_INTERVAL_MS = 120000;
79
+ const DEFAULT_CAMERA_HEALTH_HEARTBEAT_INTERVAL_MS = 60000;
80
+ // How often a running connector re-asks what cadence its operator deployed.
81
+ // Matches the sensing-profile client's own TTL so the two do not disagree about
82
+ // how stale a profile may be.
83
+ const DEFAULT_CADENCE_REFRESH_MS = 300000;
84
+ /**
85
+ * Node's `setTimeout` delay is a 32-bit SIGNED int. A larger value does not
86
+ * saturate and does not throw — it is coerced to 1 ms and the callback fires on
87
+ * the next tick. For a capture loop that inverts the request completely: "look
88
+ * once every 25 days" becomes "pull this camera as fast as ffmpeg can decode".
89
+ * Every delay this class hands to `setTimeout` goes through `clampTimerDelay`,
90
+ * so no config, no server row and no future code path can express a schedule the
91
+ * runtime will silently turn inside out. `deployedCadence.MAX_CADENCE_MS` is the
92
+ * policy bound two orders of magnitude inside this one; this is the mechanism
93
+ * bound that has to hold even if the policy one is bypassed.
94
+ */
95
+ const MAX_TIMER_DELAY_MS = 2147483647;
96
+ function clampTimerDelay(ms) {
97
+ if (!Number.isFinite(ms))
98
+ return 0;
99
+ return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, ms));
100
+ }
101
+ const AUTH_FAILURE_RE = /(\b(?:401|403)\b|unauthori[sz]ed|forbidden|authentication failed|auth failed|invalid (user|username|password)|wrong password|bad credentials|access denied)/i;
102
+ const TIMEOUT_FAILURE_RE = /(timed? out|timeout|etimedout|i\/o timeout|connection timed out|rtsp frame capture timed out)/i;
103
+ const UNREACHABLE_FAILURE_RE = /(econnrefused|connection refused|enetunreach|ehostunreach|network is unreachable|host is unreachable|no route to host|could not resolve|name or service not known|getaddrinfo|not found|404)/i;
104
+ const DECODE_FAILURE_RE = /(missing pps|non-existing pps|missing sps|non-existing sps|decode_slice_header|illegal poc|invalid nal|corrupt(?:ed)?|error while decoding|concealing \d+|reference picture missing|mmco|cabac|sps_id|pps_id)/i;
105
+ const NO_FRAMES_FAILURE_RE = /(no frame|no video|unexpected frame size|invalid jpeg|invalid data|output file is empty|end of file|eof)/i;
106
+ const NVR_BUSY_FAILURE_RE = /(\b(?:453|455)\b|not enough bandwidth|session limit|too many (?:viewers|connections|sessions)|maximum (?:viewer|connection|session)|max(?:imum)? clients)/i;
107
+ const URL_CREDENTIAL_RE = /\b((?:rtsp|rtsps|https?):\/\/)([^@\s]+)@/gi;
108
+ const SECRET_PARAM_RE = /\b(password|passwd|pwd|pass|token|deviceToken|device_token)=([^&\s]+)/gi;
109
+ const AUTH_HEADER_RE = /\b(authorization:\s*(?:basic|bearer)\s+)[^\s]+/gi;
110
+ function classifyRtspFailureClass(err) {
111
+ const message = errorMessage(err);
112
+ if (AUTH_FAILURE_RE.test(message))
113
+ return 'rtsp_auth';
114
+ if (NVR_BUSY_FAILURE_RE.test(message))
115
+ return 'nvr_busy';
116
+ if (UNREACHABLE_FAILURE_RE.test(message))
117
+ return 'rtsp_unreachable';
118
+ if (TIMEOUT_FAILURE_RE.test(message))
119
+ return 'rtsp_timeout';
120
+ if (DECODE_FAILURE_RE.test(message))
121
+ return 'decode_error';
122
+ if (NO_FRAMES_FAILURE_RE.test(message))
123
+ return 'no_frames';
124
+ return 'rtsp_unreachable';
125
+ }
126
+ function errorMessage(err) {
127
+ return err instanceof Error ? err.message : String(err ?? '');
128
+ }
129
+ function redactAndTruncateFailureMessage(err) {
130
+ return redactSensitiveFailureMessage(errorMessage(err)).slice(0, 512);
131
+ }
132
+ function redactSensitiveFailureMessage(message) {
133
+ return message
134
+ .replace(URL_CREDENTIAL_RE, '$1[redacted]@')
135
+ .replace(SECRET_PARAM_RE, '$1=[redacted]')
136
+ .replace(AUTH_HEADER_RE, '$1[redacted]');
137
+ }
138
+ function sleep(ms) {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+ class CloudSensingManager extends events_1.EventEmitter {
142
+ constructor(options) {
143
+ super();
144
+ /** The last cadence verdict applied, surfaced by getSensingHealth(). */
145
+ this.cadence = null;
146
+ this.cadenceTimer = null;
147
+ /** Retained so a cadence change can rebuild each feed's gate heartbeat. */
148
+ this.motionGateOptions = null;
149
+ /** Look-rate for a still feed when the cascade is on. null ⇒ cascade off. */
150
+ this.motionScanIntervalMs = null;
151
+ this.running = false;
152
+ this.startGeneration = 0;
153
+ this.runStartedAtMs = null;
154
+ /** One decode-only probe at a time protects recorder session limits. */
155
+ this.healthCaptureInFlight = false;
156
+ if (!options.loops || options.loops.length === 0) {
157
+ throw new Error('CloudSensingManager requires at least one capture loop');
158
+ }
159
+ this.now = options.now ?? Date.now;
160
+ const motionGateEnabled = !!options.motionGate && options.motionGate.enabled !== false;
161
+ this.loops = options.loops.map((deps, index) => ({
162
+ index,
163
+ deps,
164
+ faceDetected: false,
165
+ timer: null,
166
+ captureCount: 0,
167
+ postCount: 0,
168
+ cameraHealthRetryCount: 0,
169
+ reconnectDelayMs: null,
170
+ motionGate: null,
171
+ admissionDeniedCount: 0,
172
+ motionSkipCount: 0,
173
+ runAnalyses: 0,
174
+ runAdmissionDenied: 0,
175
+ runMotionSkipped: 0,
176
+ retryDelayMs: null,
177
+ tickInFlight: false,
178
+ lastCaptureAtMs: null,
179
+ lastHealthReportAtMs: null,
180
+ lastSensingState: null,
181
+ }));
182
+ this.periodicIntervalMs = options.periodicIntervalMs ?? DEFAULT_PERIODIC_MS;
183
+ this.faceTriggeredIntervalMs = options.faceTriggeredIntervalMs ?? DEFAULT_FACE_MS;
184
+ this.jpegQuality = options.jpegQuality ?? DEFAULT_JPEG_QUALITY;
185
+ this.skipPostWhenNoFaces = options.skipPostWhenNoFaces ?? true;
186
+ this.width = options.width;
187
+ this.height = options.height;
188
+ // Clamp the floor to 1ms so a 0/negative config can't busy-spin the loop.
189
+ this.reconnectInitialDelayMs = Math.max(1, options.reconnectInitialDelayMs ?? DEFAULT_RECONNECT_INITIAL_MS);
190
+ // The cap can never be below the initial delay (a smaller cap would make the
191
+ // "double then clamp" schedule shrink the first step).
192
+ this.reconnectMaxDelayMs = Math.max(this.reconnectInitialDelayMs, options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_MS);
193
+ this.feedStaggerMs = Math.max(0, Math.trunc(options.feedStaggerMs ?? (options.loops.length > 1 ? DEFAULT_FEED_STAGGER_MS : 0)));
194
+ this.maxFeedStaggerMs = Math.max(0, Math.trunc(options.maxFeedStaggerMs ?? DEFAULT_MAX_FEED_STAGGER_MS));
195
+ this.cameraHealthFailureGraceCount = Math.max(1, Math.trunc(options.cameraHealthFailureGraceCount ??
196
+ DEFAULT_CAMERA_HEALTH_FAILURE_GRACE_COUNT));
197
+ this.cameraHealthProbeIntervalMs = Math.max(1000, Math.trunc(options.cameraHealthProbeIntervalMs ?? DEFAULT_CAMERA_HEALTH_PROBE_INTERVAL_MS));
198
+ this.cameraHealthHeartbeatIntervalMs = Math.max(1000, Math.trunc(options.cameraHealthHeartbeatIntervalMs ?? DEFAULT_CAMERA_HEALTH_HEARTBEAT_INTERVAL_MS));
199
+ // One gate for the whole process. Defaulting to one slot PER LOOP keeps the
200
+ // cloud path (a genuinely concurrent backend) exactly as it is today; the
201
+ // local path passes 1 because its backend is single-conversation.
202
+ this.admissionGate = new VlmAdmissionGate_1.VlmAdmissionGate({
203
+ maxInFlight: Math.max(1, Math.trunc(options.maxConcurrentAnalyses ?? this.loops.length)),
204
+ maxInFlightMs: options.maxAnalysisInFlightMs,
205
+ now: this.now,
206
+ });
207
+ this.admissionRetryDelayMs = Math.max(1, Math.trunc(options.admissionRetryDelayMs ??
208
+ Math.min(MAX_ADMISSION_RETRY_MS, Math.max(MIN_ADMISSION_RETRY_MS, this.staggerStepMs()))));
209
+ if (motionGateEnabled) {
210
+ const gateOptions = options.motionGate;
211
+ this.motionScanIntervalMs = Math.max(1, Math.trunc(gateOptions.scanIntervalMs ?? this.faceTriggeredIntervalMs));
212
+ this.motionGateOptions = gateOptions;
213
+ for (const s of this.loops) {
214
+ s.motionGate = new SceneChangeDetector_1.MotionGate({
215
+ ...gateOptions,
216
+ // The gate reuses the cadence knobs the config already carries: a
217
+ // still scene still reports on the periodic cadence, and a busy one
218
+ // can never analyze faster than the face-triggered cadence.
219
+ heartbeatIntervalMs: gateOptions.heartbeatIntervalMs ?? this.periodicIntervalMs,
220
+ minAnalyzeIntervalMs: gateOptions.minAnalyzeIntervalMs ?? this.faceTriggeredIntervalMs,
221
+ now: this.now,
222
+ });
223
+ }
224
+ }
225
+ this.motionDrivesCadence = options.motionDrivesCadence ?? motionGateEnabled;
226
+ this.cadenceSource = options.cadenceSource;
227
+ this.cadenceRefreshIntervalMs = Math.max(1000, Math.trunc(options.cadenceRefreshIntervalMs ?? DEFAULT_CADENCE_REFRESH_MS));
228
+ }
229
+ /**
230
+ * Retune a RUNNING connector's still-scene cadence.
231
+ *
232
+ * Timers are NOT cancelled: each loop re-arms from `nextDelayFor` in its own
233
+ * `.finally`, so the new interval takes effect on the next tick. Cancelling
234
+ * and rescheduling here would collapse every feed's carefully-staggered phase
235
+ * onto the same instant — the multi-camera NVR stampede `staggerStepMs`
236
+ * exists to prevent — for no gain, since the worst-case lag is one old
237
+ * period.
238
+ *
239
+ * The motion gate's HEARTBEAT tracks the cadence, because the heartbeat IS
240
+ * the still-scene reporting floor: leaving it at the old value would let a
241
+ * gate keep reporting on a cadence the operator has replaced.
242
+ *
243
+ * The heartbeat is retuned IN PLACE (`MotionGate.setHeartbeatIntervalMs`) and
244
+ * NOT by rebuilding the gate. A fresh `MotionGate` is a fresh
245
+ * `SceneChangeDetector`: no background model, `calibrating` re-armed, the
246
+ * contrast ring un-seeded, the stationary signature forgotten and — the
247
+ * expensive one — `lastAnalyzedAtMs` back to null, which makes the very next
248
+ * look heartbeat-DUE and forces an inference on a scene the gate had already
249
+ * proven still. Measured: a byte-identical still frame costs 0 analyses over
250
+ * ten looks, then exactly +1 per feed the instant the cadence moves. On a
251
+ * 16-camera store that is 16 forced generations against the connector's single
252
+ * slot for a change that was meant to cost nothing. Rebuilding also swapped
253
+ * the object mid-tick, so a frame's `evaluate()` and its `markAnalyzed()`
254
+ * could land on two different gates.
255
+ *
256
+ * @returns true when the interval actually changed.
257
+ */
258
+ setPeriodicIntervalMs(intervalMs) {
259
+ const raw = Number(intervalMs);
260
+ if (!Number.isFinite(raw) || raw <= 0)
261
+ return false;
262
+ // A cadence the timer cannot express is not a cadence — see clampTimerDelay.
263
+ // Clamping HERE (not only at setTimeout) keeps getPeriodicIntervalMs
264
+ // truthful: what it reports is what the loops actually run.
265
+ const next = clampTimerDelay(Math.trunc(raw));
266
+ if (next <= 0 || next === this.periodicIntervalMs)
267
+ return false;
268
+ this.periodicIntervalMs = next;
269
+ if (this.motionGateOptions) {
270
+ // Only the DERIVED heartbeat follows the cadence. An explicitly
271
+ // configured heartbeat is the operator's own floor and stays theirs.
272
+ if (this.motionGateOptions.heartbeatIntervalMs === undefined) {
273
+ for (const s of this.loops)
274
+ s.motionGate?.setHeartbeatIntervalMs(next);
275
+ }
276
+ }
277
+ return true;
278
+ }
279
+ /** The still-scene cadence currently in force. */
280
+ getPeriodicIntervalMs() {
281
+ return this.periodicIntervalMs;
282
+ }
283
+ /**
284
+ * Ask the cadence source what the operator deployed and apply it. Best-effort
285
+ * and never throws: a failed lookup leaves the running cadence untouched,
286
+ * because a connector that resets to a default every time the API blips is
287
+ * worse than one that is briefly stale.
288
+ */
289
+ async refreshDeployedCadence() {
290
+ if (!this.cadenceSource)
291
+ return null;
292
+ let verdict = null;
293
+ try {
294
+ verdict = await this.cadenceSource.resolve();
295
+ }
296
+ catch (err) {
297
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
298
+ return null;
299
+ }
300
+ if (!verdict)
301
+ return null;
302
+ this.cadence = verdict;
303
+ if (verdict.intervalMs === null)
304
+ return verdict;
305
+ if (this.setPeriodicIntervalMs(verdict.intervalMs)) {
306
+ // A DISTINCT event from 'cadenceChanged': that one is a per-feed
307
+ // face-state switch carrying {name, intervalMs, reason}. This is a
308
+ // whole-connector retune, and overloading the same event would make a
309
+ // consumer's `reason` field mean two unrelated things.
310
+ this.emit('deployedCadenceChanged', verdict);
311
+ }
312
+ return verdict;
313
+ }
314
+ /** Number of independent capture loops (== number of feeds/tokens). */
315
+ getLoopCount() {
316
+ return this.loops.length;
317
+ }
318
+ /** Per-loop stats (capture + POST counts, cadence, cascade drops). */
319
+ getStats() {
320
+ return this.loops.map((s) => ({
321
+ name: s.deps.name,
322
+ captureCount: s.captureCount,
323
+ postCount: s.postCount,
324
+ faceDetected: s.faceDetected,
325
+ currentIntervalMs: this.intervalFor(s),
326
+ admissionDeniedCount: s.admissionDeniedCount,
327
+ motionSkipCount: s.motionSkipCount,
328
+ }));
329
+ }
330
+ /** Live admission-gate counters (admitted / dropped / stale-reclaimed). */
331
+ getAdmissionStats() {
332
+ return this.admissionGate.getStats();
333
+ }
334
+ /**
335
+ * The operator-facing answer to "why is my 16-camera store only sampling
336
+ * every N seconds?" — sustained analyses/minute is what the machine can
337
+ * actually do, and `effectiveSampleIntervalMs` is what that buys each camera.
338
+ * Drops are attributed: `admissionDenied` means the backend is the ceiling,
339
+ * `motionSkipped` means the cascade decided the frame was not worth paying
340
+ * for. Both are wins over the old failure mode, where the surplus vanished
341
+ * into 120 s timeouts.
342
+ */
343
+ getSensingHealth() {
344
+ const uptimeMs = this.runStartedAtMs === null ? 0 : Math.max(0, this.now() - this.runStartedAtMs);
345
+ const analyses = this.loops.reduce((n, s) => n + s.runAnalyses, 0);
346
+ const admissionDenied = this.loops.reduce((n, s) => n + s.runAdmissionDenied, 0);
347
+ const motionSkipped = this.loops.reduce((n, s) => n + s.runMotionSkipped, 0);
348
+ const analysesPerMinute = uptimeMs > 0 ? (analyses * 60000) / uptimeMs : 0;
349
+ // Escalation counts live on the CLIENT (it owns the policy), not on the
350
+ // loop: the manager never sees the local/cloud choice, and duplicating the
351
+ // count here would be a second home for one number.
352
+ const escalation = this.loops.map((s) => escalationStatsFor(s));
353
+ const cloudEscalated = escalation.reduce((n, e) => n + e.escalated, 0);
354
+ return {
355
+ loops: this.loops.length,
356
+ maxConcurrentAnalyses: this.admissionGate.getMaxInFlight(),
357
+ inFlight: this.admissionGate.getInFlight(),
358
+ uptimeMs,
359
+ analyses,
360
+ analysesPerMinute,
361
+ // Per CAMERA: how long between two looks by the model, given the
362
+ // throughput the machine actually sustained.
363
+ effectiveSampleIntervalMs: analyses > 0 && uptimeMs > 0 ? (uptimeMs * this.loops.length) / analyses : null,
364
+ periodicIntervalMs: this.periodicIntervalMs,
365
+ deployedCadence: this.cadence,
366
+ admissionDenied,
367
+ motionSkipped,
368
+ cloudEscalated,
369
+ cloudEscalationEnabled: escalation.some((e) => e.enabled),
370
+ perFeed: this.loops.map((s, i) => ({
371
+ name: s.deps.name,
372
+ captures: s.captureCount,
373
+ analyses: s.runAnalyses,
374
+ admissionDenied: s.runAdmissionDenied,
375
+ motionSkipped: s.runMotionSkipped,
376
+ cloudEscalated: escalation[i].escalated,
377
+ faceDetected: s.faceDetected,
378
+ currentIntervalMs: this.intervalFor(s),
379
+ })),
380
+ };
381
+ }
382
+ isRunning() {
383
+ return this.running;
384
+ }
385
+ /** Open cameras, prove one frame, and start capture loops without one slow feed blocking others. */
386
+ async start() {
387
+ if (this.running)
388
+ return;
389
+ this.running = true;
390
+ this.runStartedAtMs = this.now();
391
+ const generation = ++this.startGeneration;
392
+ for (const s of this.loops) {
393
+ // A stop()/start() cycle may resume on a different view (re-paired
394
+ // camera, changed mosaic crop), so the background model must not carry
395
+ // over — a stale baseline reports a whole-frame change that never
396
+ // happened, and the heartbeat clock restarts with the run.
397
+ s.motionGate?.reset();
398
+ s.runAnalyses = 0;
399
+ s.runAdmissionDenied = 0;
400
+ s.runMotionSkipped = 0;
401
+ void this.startLoop(s, generation).catch((err) => this.emitLoopError(s, err));
402
+ }
403
+ // Ask once at startup so a connector picks up its deployed cadence on boot
404
+ // rather than after a full refresh period, then keep asking. Both are
405
+ // best-effort: refreshDeployedCadence never throws and never resets the
406
+ // cadence on failure.
407
+ if (this.cadenceSource) {
408
+ void this.refreshDeployedCadence();
409
+ this.cadenceTimer = setInterval(() => {
410
+ void this.refreshDeployedCadence();
411
+ }, this.cadenceRefreshIntervalMs);
412
+ // Never hold the process open for a cadence poll.
413
+ if (typeof this.cadenceTimer.unref === 'function')
414
+ this.cadenceTimer.unref();
415
+ }
416
+ }
417
+ /** Stop all loops and close cameras. */
418
+ async stop() {
419
+ this.running = false;
420
+ this.startGeneration++;
421
+ this.runStartedAtMs = null;
422
+ // Drop every held slot: a capture still unwinding must not leave the gate
423
+ // closed against the NEXT run.
424
+ this.admissionGate.reset();
425
+ this.healthCaptureInFlight = false;
426
+ if (this.cadenceTimer) {
427
+ clearInterval(this.cadenceTimer);
428
+ this.cadenceTimer = null;
429
+ }
430
+ for (const s of this.loops) {
431
+ if (s.timer) {
432
+ clearTimeout(s.timer);
433
+ s.timer = null;
434
+ }
435
+ s.retryDelayMs = null;
436
+ s.tickInFlight = false;
437
+ }
438
+ for (const s of this.loops) {
439
+ try {
440
+ if (s.deps.camera.isOpen()) {
441
+ await s.deps.camera.close();
442
+ }
443
+ }
444
+ catch {
445
+ // best-effort close
446
+ }
447
+ }
448
+ }
449
+ /**
450
+ * Notify a loop (by feed name) that its face-detection state changed —
451
+ * switches that loop between the periodic and face-triggered cadence.
452
+ */
453
+ setFaceDetected(loopName, detected) {
454
+ const s = this.loops.find((l) => l.deps.name === loopName);
455
+ if (!s)
456
+ return;
457
+ const prev = s.faceDetected;
458
+ s.faceDetected = detected;
459
+ if (prev === detected || !this.running)
460
+ return;
461
+ this.emit('cadenceChanged', {
462
+ name: s.deps.name,
463
+ intervalMs: this.intervalFor(s),
464
+ reason: detected ? 'face_detected' : 'no_faces',
465
+ });
466
+ // A tick already running re-arms from its own `.finally` and will read the
467
+ // interval we just changed. Arming here as well is the same double-chain
468
+ // `applyMotionCadence` avoids by construction — the in-tick twin of this
469
+ // method — except this one is public and can be called at any instant.
470
+ if (s.tickInFlight)
471
+ return;
472
+ if (s.timer) {
473
+ clearTimeout(s.timer);
474
+ s.timer = null;
475
+ }
476
+ this.scheduleNext(s, this.startGeneration);
477
+ }
478
+ /**
479
+ * Same cadence switch, but from INSIDE a running tick: record the new state
480
+ * and announce it WITHOUT rescheduling. The tick's own `finally` already
481
+ * calls scheduleNext and will read the updated interval — rescheduling here
482
+ * as well would leave the feed running two concurrent timers.
483
+ */
484
+ applyMotionCadence(s, motion) {
485
+ if (s.faceDetected === motion)
486
+ return;
487
+ s.faceDetected = motion;
488
+ this.emit('cadenceChanged', {
489
+ name: s.deps.name,
490
+ intervalMs: this.intervalFor(s),
491
+ reason: motion ? 'face_detected' : 'no_faces',
492
+ });
493
+ }
494
+ /**
495
+ * Capture + (conditionally) POST one frame for a given loop, immediately.
496
+ *
497
+ * Goes through the SAME cascade as a scheduled tick — an out-of-band capture
498
+ * that jumped the admission gate would break the very invariant the gate
499
+ * exists to hold. Returns `null` (rather than waiting) when the gate is
500
+ * closed or the motion stage judged the frame not worth an inference.
501
+ */
502
+ async captureNow(loopName) {
503
+ const s = this.loops.find((l) => l.deps.name === loopName);
504
+ if (!s)
505
+ return null;
506
+ return this.captureAndPost(s);
507
+ }
508
+ // ─── Private ────────────────────────────────────────────────────────────────
509
+ isCurrentStart(generation) {
510
+ return this.running && this.startGeneration === generation;
511
+ }
512
+ async startLoop(s, generation) {
513
+ // Fresh start → clear any stale backoff from a previous run (so a
514
+ // stop()→start() cycle resumes at the normal cadence, not a climbed delay).
515
+ s.reconnectDelayMs = null;
516
+ s.cameraHealthRetryCount = 0;
517
+ s.lastCaptureAtMs = null;
518
+ s.lastHealthReportAtMs = null;
519
+ s.lastSensingState = null;
520
+ let needsOpen = !s.deps.camera.isOpen();
521
+ const startupDelayMs = this.staggerFor(s);
522
+ if (startupDelayMs > 0) {
523
+ await sleep(startupDelayMs);
524
+ }
525
+ if (!this.isCurrentStart(generation))
526
+ return;
527
+ needsOpen = !s.deps.camera.isOpen();
528
+ if (needsOpen) {
529
+ // A camera unreachable at startup must NOT crash the connector — enter
530
+ // reconnect mode so the loop keeps retrying until the feed comes up,
531
+ // exactly as it does for a mid-run drop. Other feeds start independently.
532
+ try {
533
+ await s.deps.camera.open();
534
+ if (!this.isCurrentStart(generation)) {
535
+ if (!this.running) {
536
+ await this.closeCameraQuietly(s);
537
+ }
538
+ return;
539
+ }
540
+ this.reportCameraOpen(s, { frameProven: true, sensingState: 'idle' });
541
+ }
542
+ catch (err) {
543
+ if (!this.isCurrentStart(generation)) {
544
+ if (!this.running) {
545
+ await this.closeCameraQuietly(s);
546
+ }
547
+ return;
548
+ }
549
+ this.reportCameraFailure(s, err);
550
+ this.emitLoopError(s, err);
551
+ this.enterReconnect(s);
552
+ }
553
+ }
554
+ else {
555
+ this.reportCameraOpen(s, { sensingState: 'idle' });
556
+ }
557
+ // Do not leave a newly paired camera at "Connecting..." until the first
558
+ // periodic cadence (five minutes by default). The startup stagger above
559
+ // still protects multi-channel recorders from a connection burst, while
560
+ // this first capture gives the portal real proof of video promptly.
561
+ if (this.isCurrentStart(generation) && s.deps.camera.isOpen()) {
562
+ // Counts as a tick in flight: this call also ends in a scheduleNext, so a
563
+ // concurrent setFaceDetected must not arm a second chain underneath it.
564
+ s.tickInFlight = true;
565
+ try {
566
+ await this.captureAndPost(s);
567
+ }
568
+ catch (err) {
569
+ if (!this.isCurrentStart(generation))
570
+ return;
571
+ if (!s.deps.camera.isOpen()) {
572
+ this.reportCameraFailure(s, err);
573
+ this.enterReconnect(s);
574
+ }
575
+ this.emitLoopError(s, err);
576
+ }
577
+ finally {
578
+ s.tickInFlight = false;
579
+ }
580
+ }
581
+ this.scheduleNext(s, generation);
582
+ }
583
+ async closeCameraQuietly(s) {
584
+ try {
585
+ if (s.deps.camera.isOpen()) {
586
+ await s.deps.camera.close();
587
+ }
588
+ }
589
+ catch {
590
+ // best-effort close
591
+ }
592
+ }
593
+ /**
594
+ * How long until this feed's next LOOK.
595
+ *
596
+ * Without the cascade this is the historical rule: fast while faces are
597
+ * present, slow otherwise. With the cascade on, a still feed keeps looking at
598
+ * the scan cadence — looking is cheap and only analysis is gated — and
599
+ * `periodicIntervalMs` becomes the gate's heartbeat, i.e. the floor on
600
+ * REPORTING rather than the rate of watching.
601
+ */
602
+ intervalFor(s) {
603
+ if (s.faceDetected)
604
+ return this.faceTriggeredIntervalMs;
605
+ return this.motionScanIntervalMs ?? this.periodicIntervalMs;
606
+ }
607
+ /**
608
+ * Per-feed phase step, derived from FEED COUNT and CADENCE instead of a fixed
609
+ * per-feed cap.
610
+ *
611
+ * The spread window is the smaller of the configured `maxFeedStaggerMs` and
612
+ * the fastest capture cadence (a spread longer than one period would just
613
+ * re-alias), and the step is the largest value that fits every feed inside
614
+ * it, never exceeding the configured `feedStaggerMs`. Small installs keep
615
+ * exactly today's offsets (4 feeds x 1000 ms); a 16-camera store gets 16
616
+ * DISTINCT offsets instead of feeds 15+ colliding on the cap.
617
+ */
618
+ staggerStepMs() {
619
+ if (this.feedStaggerMs <= 0 || this.loops.length <= 1)
620
+ return 0;
621
+ const window = Math.min(this.maxFeedStaggerMs, this.periodicIntervalMs, this.faceTriggeredIntervalMs);
622
+ if (window <= 0)
623
+ return 0;
624
+ return Math.max(1, Math.min(this.feedStaggerMs, Math.floor(window / this.loops.length)));
625
+ }
626
+ staggerFor(s) {
627
+ if (s.index <= 0)
628
+ return 0;
629
+ return s.index * this.staggerStepMs();
630
+ }
631
+ /**
632
+ * Delay before the next tick.
633
+ *
634
+ * - Camera dropped → the reconnect backoff, PLUS the phase offset so a
635
+ * recorder that rebooted does not get all N feeds re-opening in lock-step.
636
+ * - Admission denied → a short one-shot retry (consumed here) so the loop
637
+ * takes the inference slot as soon as it frees rather than idling a full
638
+ * cadence.
639
+ * - Otherwise → exactly the capture cadence. The phase offset is applied ONCE
640
+ * at startup (and on reconnect); adding it to every tick would make feed i's
641
+ * period `cadence + offset_i`, quietly stretching the last feed's cadence by
642
+ * the whole spread window — a 47 % slower camera 16 at the 30 s cadence.
643
+ * Equal periods preserve the startup phase difference forever.
644
+ */
645
+ nextDelayFor(s) {
646
+ if (s.reconnectDelayMs != null)
647
+ return s.reconnectDelayMs + this.staggerFor(s);
648
+ if (s.retryDelayMs != null) {
649
+ const delay = s.retryDelayMs;
650
+ s.retryDelayMs = null;
651
+ return delay;
652
+ }
653
+ return this.intervalFor(s);
654
+ }
655
+ /**
656
+ * Arm this feed's next tick, for ONE run of the manager.
657
+ *
658
+ * `generation` is not decoration. A tick re-arms from its own `.finally`, and
659
+ * that `.finally` can run AFTER a stop()/start() cycle — the auto-sense
660
+ * supervisor stops and restarts the SAME manager instance every time
661
+ * `litert-lm` appears or disappears, and a cloud analyze can be in flight for
662
+ * up to two minutes. Without the generation the surviving tick re-arms a
663
+ * chain, `startLoop` arms another, and `s.timer` holds only the second one, so
664
+ * `stop()` can never clear the first: the feed now captures twice per cadence,
665
+ * permanently, and every further flap adds another chain. Measured: two
666
+ * pending timers for ONE feed after a single cycle. On the cloud path
667
+ * `maxConcurrentAnalyses` defaults to one slot PER LOOP, so the duplicate
668
+ * chain is duplicate Vertex spend, not just duplicate RTSP load.
669
+ */
670
+ scheduleNext(s, generation) {
671
+ if (!this.isCurrentStart(generation))
672
+ return;
673
+ const delayMs = clampTimerDelay(this.nextDelayFor(s));
674
+ s.timer = setTimeout(() => {
675
+ if (!this.isCurrentStart(generation))
676
+ return;
677
+ s.tickInFlight = true;
678
+ this.runScheduledTick(s)
679
+ .catch((err) => this.emitLoopError(s, err))
680
+ .finally(() => {
681
+ s.tickInFlight = false;
682
+ this.scheduleNext(s, generation);
683
+ });
684
+ }, delayMs);
685
+ }
686
+ /**
687
+ * One scheduled loop iteration with built-in RTSP self-recovery.
688
+ *
689
+ * If the camera is dropped (closed), this attempts to RE-OPEN it (the keystone
690
+ * 24/7 fix — a transient feed/network blip used to wedge the loop on "camera
691
+ * is not open" forever). On a successful re-open the backoff resets and the
692
+ * tick falls through to a normal capture so sensing resumes immediately; on a
693
+ * failed re-open the backoff doubles (capped) and we retry FOREVER. The
694
+ * per-attempt connect timeout still lives in the RTSP provider's open() — only
695
+ * the give-up-forever behavior changes to retry-forever.
696
+ */
697
+ async runScheduledTick(s) {
698
+ if (!s.deps.camera.isOpen()) {
699
+ // Camera dropped. If we just discovered the closure (not yet in reconnect
700
+ // mode), arm the backoff and let the NEXT tick — scheduled at the initial
701
+ // delay — do the actual re-open, so the first attempt always waits the
702
+ // initial backoff regardless of how the drop was detected.
703
+ if (s.reconnectDelayMs == null) {
704
+ this.enterReconnect(s);
705
+ return;
706
+ }
707
+ const ok = await this.tryReopen(s);
708
+ if (!ok)
709
+ return; // still down — backoff already armed for the next tick
710
+ }
711
+ // Camera is open (healthy, or just recovered) → capture as usual.
712
+ try {
713
+ await this.captureAndPost(s);
714
+ }
715
+ catch (err) {
716
+ // A capture failure that drops the camera (the RTSP provider gives up its
717
+ // per-attempt retries and re-closes itself) must hand off to the backoff
718
+ // re-open path, NOT silently wait a full cadence interval. Arm reconnect
719
+ // mode so the next tick fires at the initial backoff and starts re-opening.
720
+ if (!s.deps.camera.isOpen()) {
721
+ this.reportCameraFailure(s, err);
722
+ this.enterReconnect(s);
723
+ }
724
+ throw err; // surface to the loop's error handler (emits 'error')
725
+ }
726
+ }
727
+ /**
728
+ * Attempt one camera re-open. Returns true on success (backoff cleared, sensing
729
+ * resumes), false on failure (backoff doubled+capped for the next attempt).
730
+ * Emits `reconnecting` before each attempt and `reconnected` on recovery so
731
+ * operators can observe the self-healing on an unattended connector.
732
+ */
733
+ async tryReopen(s) {
734
+ const attemptDelayMs = s.reconnectDelayMs ?? this.reconnectInitialDelayMs;
735
+ this.emit('reconnecting', { name: s.deps.name, delayMs: attemptDelayMs });
736
+ try {
737
+ await s.deps.camera.open();
738
+ s.cameraHealthRetryCount = 0;
739
+ s.reconnectDelayMs = null; // recovered → reset backoff to healthy cadence
740
+ // The background model is as old as the outage — and the camera may have
741
+ // come back pointing somewhere else. Re-seed rather than report the whole
742
+ // frame as "changed". The heartbeat clock is deliberately NOT reset: an
743
+ // outage must not delay the feed's next guaranteed report.
744
+ s.motionGate?.reset();
745
+ this.reportCameraOpen(s, { frameProven: true, sensingState: 'idle' });
746
+ this.emit('reconnected', { name: s.deps.name });
747
+ return true;
748
+ }
749
+ catch (err) {
750
+ this.reportCameraFailure(s, err);
751
+ this.emitLoopError(s, err);
752
+ // Double the backoff, clamped at the cap (then it holds at the cap and
753
+ // keeps retrying — never gives up).
754
+ s.reconnectDelayMs = Math.min(attemptDelayMs * 2, this.reconnectMaxDelayMs);
755
+ return false;
756
+ }
757
+ }
758
+ /**
759
+ * Put a loop into reconnect mode (camera dropped). Idempotent: an already
760
+ * dropped loop keeps its current (climbing) backoff rather than resetting to
761
+ * the initial delay.
762
+ */
763
+ enterReconnect(s) {
764
+ if (s.reconnectDelayMs == null) {
765
+ s.reconnectDelayMs = this.reconnectInitialDelayMs;
766
+ }
767
+ // A dropped camera outranks a busy model: drop any pending admission retry
768
+ // so the two schedules can never interleave. Reconnect is never gated —
769
+ // a saturated model must not be able to turn a blip into permanent
770
+ // blindness on an unattended 24/7 connector.
771
+ s.retryDelayMs = null;
772
+ }
773
+ emitLoopError(s, err) {
774
+ const error = err instanceof Error ? err : new Error(String(err));
775
+ this.emit('error', { name: s.deps.name, error });
776
+ }
777
+ reportCameraFailure(s, err) {
778
+ s.cameraHealthRetryCount += 1;
779
+ const lastFailureClass = classifyRtspFailureClass(err);
780
+ if (lastFailureClass !== 'rtsp_auth' &&
781
+ s.cameraHealthRetryCount < this.cameraHealthFailureGraceCount) {
782
+ return;
783
+ }
784
+ this.reportCameraHealth(s, {
785
+ state: 'failed',
786
+ retryCount: s.cameraHealthRetryCount,
787
+ lastFailureClass,
788
+ lastFailureMessage: redactAndTruncateFailureMessage(err),
789
+ });
790
+ }
791
+ reportCameraHealth(s, cameraHealth) {
792
+ if (typeof s.deps.client.reportCameraHealth !== 'function')
793
+ return;
794
+ s.lastHealthReportAtMs = this.now();
795
+ if (cameraHealth.sensingState)
796
+ s.lastSensingState = cameraHealth.sensingState;
797
+ s.deps.client.reportCameraHealth(cameraHealth).catch((err) => {
798
+ this.emit('healthReportError', {
799
+ name: s.deps.name,
800
+ error: err instanceof Error ? err : new Error(String(err)),
801
+ });
802
+ });
803
+ }
804
+ reportCameraOpen(s, options = {}) {
805
+ const sensingState = options.sensingState ?? s.lastSensingState ?? 'idle';
806
+ const now = this.now();
807
+ const stateChanged = s.lastSensingState !== sensingState;
808
+ const heartbeatDue = s.lastHealthReportAtMs == null ||
809
+ now - s.lastHealthReportAtMs >= this.cameraHealthHeartbeatIntervalMs;
810
+ if (!options.frameProven && !options.force && !stateChanged && !heartbeatDue)
811
+ return;
812
+ if (options.frameProven)
813
+ s.lastCaptureAtMs = now;
814
+ this.reportCameraHealth(s, {
815
+ state: 'open',
816
+ retryCount: 0,
817
+ sensingState,
818
+ ...(options.frameProven
819
+ ? { lastFrameAtMs: now, captureProof: 'decoded_frame' }
820
+ : {}),
821
+ });
822
+ }
823
+ healthProbeDue(s) {
824
+ return (!this.healthCaptureInFlight &&
825
+ (s.lastCaptureAtMs == null || this.now() - s.lastCaptureAtMs >= this.cameraHealthProbeIntervalMs));
826
+ }
827
+ /**
828
+ * Prove video locally without waiting for the model. The global boolean keeps
829
+ * legacy recorders to one extra short-lived ffmpeg pull at a time.
830
+ */
831
+ async captureHealthFrame(s) {
832
+ this.healthCaptureInFlight = true;
833
+ try {
834
+ await s.deps.camera.getFrame();
835
+ s.captureCount++;
836
+ this.reportCameraOpen(s, {
837
+ frameProven: true,
838
+ sensingState: 'waiting_for_capacity',
839
+ force: true,
840
+ });
841
+ this.emit('frameCaptured', {
842
+ name: s.deps.name,
843
+ faceDetected: s.faceDetected,
844
+ healthOnly: true,
845
+ });
846
+ }
847
+ finally {
848
+ this.healthCaptureInFlight = false;
849
+ }
850
+ }
851
+ /**
852
+ * One cascade pass for a feed: admission → capture → motion → inference,
853
+ * with a bounded decode-only health probe when admission is denied.
854
+ *
855
+ * The admission check comes first so denied inference frames are dropped,
856
+ * never buffered. Video health is independent: at most one feed at a time
857
+ * may take a slow-cadence decode-only proof while the model is occupied.
858
+ */
859
+ async captureAndPost(s) {
860
+ if (!s.deps.camera.isOpen()) {
861
+ throw new Error(`CloudSensingManager: camera "${s.deps.name}" is not open`);
862
+ }
863
+ const ticket = this.admissionGate.tryAcquire(s.deps.name);
864
+ if (!ticket) {
865
+ s.admissionDeniedCount++;
866
+ s.runAdmissionDenied++;
867
+ // Poll back soon instead of idling a whole cadence: the slot frees when
868
+ // the current inference lands, which is usually seconds away.
869
+ s.retryDelayMs = this.admissionRetryDelayMs;
870
+ this.emit('admissionDenied', {
871
+ name: s.deps.name,
872
+ retryInMs: this.admissionRetryDelayMs,
873
+ inFlight: this.admissionGate.getInFlight(),
874
+ maxInFlight: this.admissionGate.getMaxInFlight(),
875
+ });
876
+ this.reportCameraOpen(s, { sensingState: 'waiting_for_capacity' });
877
+ if (this.healthProbeDue(s)) {
878
+ await this.captureHealthFrame(s);
879
+ }
880
+ return null;
881
+ }
882
+ try {
883
+ const frame = await s.deps.camera.getFrame();
884
+ // Resolve frame dimensions from the camera so raw RGB24 buffers always
885
+ // encode — works for every feed regardless of config shape (multi-channel
886
+ // feeds carry no top-level camera.width/height).
887
+ const res = safeGetResolution(s.deps.camera);
888
+ const jpeg = await this.toJpeg(frame, res?.width, res?.height);
889
+ s.captureCount++;
890
+ this.reportCameraOpen(s, {
891
+ frameProven: true,
892
+ sensingState: 'analyzing',
893
+ force: true,
894
+ });
895
+ this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
896
+ // Cheap stage: is this frame worth the expensive one? Releasing the slot
897
+ // here (via the finally) costs the backend nothing but one grab.
898
+ if (s.motionGate) {
899
+ const verdict = await s.motionGate.evaluate(jpeg);
900
+ if (this.motionDrivesCadence) {
901
+ this.applyMotionCadence(s, verdict.motion);
902
+ }
903
+ if (!verdict.analyze) {
904
+ s.motionSkipCount++;
905
+ s.runMotionSkipped++;
906
+ this.emit('motionSkipped', {
907
+ name: s.deps.name,
908
+ reason: verdict.reason,
909
+ pctMotion: verdict.pctMotion,
910
+ regions: verdict.boxes.length,
911
+ });
912
+ this.reportCameraOpen(s, { sensingState: 'idle' });
913
+ return null;
914
+ }
915
+ }
916
+ // With the cascade on, the motion gate is the SOLE authority on whether a
917
+ // frame is analyzed — one decision, one place. Without it, the historical
918
+ // rule stands: POST when faces are present OR on the periodic cadence.
919
+ const shouldPost = s.motionGate ? true : s.faceDetected || !this.skipPostWhenNoFaces;
920
+ if (!shouldPost) {
921
+ this.reportCameraOpen(s, { sensingState: 'idle' });
922
+ return null;
923
+ }
924
+ let response;
925
+ try {
926
+ response = await s.deps.client.analyze({
927
+ jpeg,
928
+ captureMode: s.faceDetected ? 'face_triggered' : 'periodic',
929
+ });
930
+ }
931
+ catch (err) {
932
+ this.reportCameraOpen(s, { sensingState: 'unavailable' });
933
+ throw err;
934
+ }
935
+ s.postCount++;
936
+ s.runAnalyses++;
937
+ // Stamp the heartbeat only once the call actually landed, so a frame lost
938
+ // to the network cannot silence a feed for a whole heartbeat window.
939
+ s.motionGate?.markAnalyzed();
940
+ this.reportCameraOpen(s, { sensingState: 'active' });
941
+ this.emit('analyzed', { name: s.deps.name, response });
942
+ return response;
943
+ }
944
+ finally {
945
+ this.admissionGate.release(ticket);
946
+ }
947
+ }
948
+ /**
949
+ * Convert a camera frame to JPEG bytes for the POST.
950
+ *
951
+ * RTSP frames arrive as raw RGB24 Buffers, so we encode them with `sharp`
952
+ * when available (the standard sensing dep). If `sharp` is missing AND the
953
+ * frame is already JPEG (some providers emit JPEG directly), we ship it
954
+ * as-is; raw-RGB-without-sharp is rejected loudly so it never silently POSTs
955
+ * an un-decodable frame.
956
+ *
957
+ * Dimensions are needed to decode raw RGB24. Resolution order: the camera's
958
+ * own getResolution() (passed in by the caller — works for multi-channel
959
+ * feeds with no top-level camera.width/height), then the manager's configured
960
+ * width/height, then EdgeImageData's own dims.
961
+ */
962
+ async toJpeg(frame, camWidth, camHeight) {
963
+ const buffer = Buffer.isBuffer(frame) ? frame : Buffer.from(frame.data.buffer);
964
+ // Already JPEG? (SOI marker 0xFFD8) — ship as-is.
965
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8) {
966
+ return buffer;
967
+ }
968
+ try {
969
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
970
+ const sharp = require('sharp');
971
+ const width = camWidth ?? this.width ?? (Buffer.isBuffer(frame) ? undefined : frame.width);
972
+ const height = camHeight ?? this.height ?? (Buffer.isBuffer(frame) ? undefined : frame.height);
973
+ if (width && height) {
974
+ return await sharp(buffer, { raw: { width, height, channels: 3 } })
975
+ .jpeg({ quality: this.jpegQuality })
976
+ .toBuffer();
977
+ }
978
+ // Unknown dimensions — let sharp sniff the format.
979
+ return await sharp(buffer).jpeg({ quality: this.jpegQuality }).toBuffer();
980
+ }
981
+ catch (err) {
982
+ throw new Error(`CloudSensingManager: cannot encode raw frame to JPEG (install "sharp" or ` +
983
+ `have the camera emit JPEG): ${err instanceof Error ? err.message : err}`);
984
+ }
985
+ }
986
+ }
987
+ exports.CloudSensingManager = CloudSensingManager;
988
+ /**
989
+ * Read a loop's cloud-escalation counters defensively. The cloud client does not
990
+ * implement `getEscalationStats` at all (it has nothing to escalate to), and a
991
+ * test double may not either, so an absent method reads as "no escalation" —
992
+ * never as an error on the health path.
993
+ */
994
+ function escalationStatsFor(s) {
995
+ try {
996
+ const stats = s.deps.client.getEscalationStats?.();
997
+ if (stats && typeof stats.escalated === 'number') {
998
+ return { enabled: stats.enabled === true, escalated: stats.escalated };
999
+ }
1000
+ }
1001
+ catch {
1002
+ // health must never throw
1003
+ }
1004
+ return { enabled: false, escalated: 0 };
1005
+ }
1006
+ /** Read a camera's resolution defensively (providers may not implement it). */
1007
+ function safeGetResolution(camera) {
1008
+ try {
1009
+ const res = camera.getResolution?.();
1010
+ if (res && res.width > 0 && res.height > 0) {
1011
+ return res;
1012
+ }
1013
+ }
1014
+ catch {
1015
+ // ignore — fall back to configured/EdgeImageData dims
1016
+ }
1017
+ return undefined;
1018
+ }
1019
+ /**
1020
+ * Build the per-feed capture loops from an EdgeAgentConfig — the bridge between
1021
+ * config (`cameras: [...]` multi-channel, or single `camera`) and the manager.
1022
+ *
1023
+ * - Multi-channel: one loop per `cameras[]` entry, each with its own token +
1024
+ * its own RTSP provider.
1025
+ * - Single-camera (back-compat): one loop from the top-level `camera`/token.
1026
+ *
1027
+ * `rtspProviderFactory` / `clientFactory` are injectable for tests; defaults
1028
+ * use the neutral RTSP package + AudienceAnalyzeClient.
1029
+ */
1030
+ function buildSensingLoops(config, deps) {
1031
+ const feeds = resolveFeeds(config);
1032
+ if (feeds.length === 0) {
1033
+ throw new Error('buildSensingLoops: no camera feeds configured (set `cameras: [...]` or `camera.rtspUrl`)');
1034
+ }
1035
+ const rtspFactory = deps?.rtspProviderFactory ??
1036
+ ((feed) => {
1037
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1038
+ const { RTSPCameraProvider } = require('@trillboards/edge-platform-rtsp');
1039
+ return new RTSPCameraProvider({
1040
+ rtspUrl: feed.rtspUrl,
1041
+ width: feed.width,
1042
+ height: feed.height,
1043
+ fps: feed.fps,
1044
+ transport: feed.transport,
1045
+ ffmpegPath: feed.ffmpegPath,
1046
+ // Per-tile crop in the SAME pass that pulls the RTSP — used for the solo
1047
+ // path (a single tile, or a group with no designated source feed). When
1048
+ // a source feed IS present the shared-pull path below handles the carve
1049
+ // and never sets feed.crop on the grabber.
1050
+ crop: feed.crop,
1051
+ // Encode to JPEG in ffmpeg so the POST needs no image library (no sharp).
1052
+ outputFormat: feed.outputFormat ?? 'jpeg',
1053
+ jpegQuality: feed.jpegQuality,
1054
+ ...(deps?.streamUrlResolver
1055
+ ? {
1056
+ streamUrlResolver: (currentUrl, error) => deps.streamUrlResolver(feed, currentUrl, error),
1057
+ }
1058
+ : {}),
1059
+ ...(0, legacyRecorderProfile_1.legacyRecorderRtspOptions)(feed.sourceType),
1060
+ });
1061
+ });
1062
+ const clientFactory = deps?.clientFactory ??
1063
+ ((feed) => new AudienceAnalyzeClient_1.AudienceAnalyzeClient({
1064
+ apiBaseUrl: config.apiBaseUrl,
1065
+ deviceToken: feed.deviceToken,
1066
+ screenId: feed.screenId,
1067
+ imageWidth: feed.width,
1068
+ imageHeight: feed.height,
1069
+ }, deps?.httpPoster));
1070
+ const loop = (feed, camera) => ({
1071
+ name: feed.name,
1072
+ camera,
1073
+ client: clientFactory(feed),
1074
+ });
1075
+ // Group feeds that share ONE physical RTSP pull. A feed opts into sharing only
1076
+ // by `sourceFeedId` (a tile → its mosaic source) or by being that source
1077
+ // (`id`). Mosaic-aware camera construction is shared with local sensing — see
1078
+ // buildTiledCameras — so the carve/share behavior can NEVER diverge between the
1079
+ // cloud and local run modes.
1080
+ return buildTiledCameras(feeds, rtspFactory, {
1081
+ sharedFrameMaxAgeMs: deps?.sharedFrameMaxAgeMs,
1082
+ sharedSources: deps?.sharedSources,
1083
+ }).map(({ feed, camera }) => loop(feed, camera));
1084
+ }
1085
+ /**
1086
+ * Build one CameraProvider per feed, collapsing mosaic tiles that share a source
1087
+ * onto a SINGLE shared RTSP pull. This is the ONE implementation of mosaic
1088
+ * tiling, used by BOTH cloud-sense (buildSensingLoops) and local-sense
1089
+ * (buildLocalSensingLoops) — no split-brain between run modes. Returns feed↔camera
1090
+ * pairs so each caller attaches its own sensing client.
1091
+ *
1092
+ * Grouping: a feed opts into sharing a pull only via `sourceFeedId` (a tile → its
1093
+ * mosaic source) or by being that source (`id`); a feed with neither is solo
1094
+ * (keyed uniquely) and gets exactly the historical one-provider-per-feed path.
1095
+ *
1096
+ * `rtspFactory` builds the underlying RTSP provider and MUST honor `feed.crop`
1097
+ * (used by the per-tile-pull fallback when no source feed is configured).
1098
+ */
1099
+ function buildTiledCameras(feeds, rtspFactory, opts) {
1100
+ const groups = new Map();
1101
+ feeds.forEach((feed, i) => {
1102
+ const key = feed.sourceFeedId ?? feed.id ?? `__solo_${i}`;
1103
+ const arr = groups.get(key);
1104
+ if (arr)
1105
+ arr.push(feed);
1106
+ else
1107
+ groups.set(key, [feed]);
1108
+ });
1109
+ const out = [];
1110
+ for (const [key, members] of groups) {
1111
+ // Back-compat fast path: a lone feed with no crop → exactly today's provider.
1112
+ if (members.length === 1 && !members[0].crop) {
1113
+ out.push({ feed: members[0], camera: rtspFactory(members[0]) });
1114
+ continue;
1115
+ }
1116
+ // The designated source feed (its `id` == the group key) carries the full
1117
+ // mosaic rtspUrl + native resolution, so we can pull it ONCE and crop each
1118
+ // tile from that single frame.
1119
+ const source = members.find((f) => f.id === key);
1120
+ if (source) {
1121
+ // ONE pull per mosaic source, shared across consumers via opts.sharedSources
1122
+ // (the VLM rail + the ONNX zoo rail attach to the same SharedFrameSource).
1123
+ // The grabber ALWAYS pulls JPEG (crop:undefined, native res) so the per-tile
1124
+ // ffmpeg carve (`-i pipe:0`) can decode it regardless of the per-tile output
1125
+ // format — the pixel space the tile crops are relative to.
1126
+ let shared = opts?.sharedSources?.get(key);
1127
+ if (!shared) {
1128
+ const grabber = rtspFactory({ ...source, crop: undefined, outputFormat: 'jpeg' });
1129
+ shared = new MosaicSource_1.SharedFrameSource(grabber, { maxAgeMs: opts?.sharedFrameMaxAgeMs });
1130
+ opts?.sharedSources?.set(key, shared);
1131
+ }
1132
+ const sourceWidth = source.width;
1133
+ const sourceHeight = source.height;
1134
+ for (const m of members) {
1135
+ const cropped = !!m.crop;
1136
+ out.push({
1137
+ feed: m,
1138
+ camera: new MosaicSource_1.CropViewProvider(shared, {
1139
+ crop: m.crop,
1140
+ // A cropped tile defaults to its crop's NATURAL size (a 1:1 carve) —
1141
+ // never the full mosaic res, which would upscale + distort the tile.
1142
+ // A full-frame member (no crop) defaults to the source's own res.
1143
+ width: m.width ?? (cropped ? m.crop.w : sourceWidth) ?? 640,
1144
+ height: m.height ?? (cropped ? m.crop.h : sourceHeight) ?? 480,
1145
+ // Used only for the full-frame fast-path (skip re-encode when a
1146
+ // no-crop JPEG view already matches the source res).
1147
+ sourceWidth,
1148
+ sourceHeight,
1149
+ fps: m.fps,
1150
+ ffmpegPath: m.ffmpegPath,
1151
+ jpegQuality: m.jpegQuality,
1152
+ outputFormat: opts?.cropOutputFormat,
1153
+ }),
1154
+ });
1155
+ }
1156
+ continue;
1157
+ }
1158
+ // Tiles reference a source that wasn't configured as its own feed. Each tile
1159
+ // already carries the (inherited or explicit) rtspUrl, so fall back to a
1160
+ // per-tile pull that crops in one ffmpeg pass — correct output, just one RTSP
1161
+ // session per tile. Warn so the operator can add the source feed to collapse
1162
+ // them onto a single pull.
1163
+ if (members.length > 1) {
1164
+ // eslint-disable-next-line no-console
1165
+ console.warn(`cloud-sense: ${members.length} feeds share sourceFeedId="${key}" but no source feed ` +
1166
+ `(a feed with id="${key}") is configured — each tile will pull the RTSP independently. ` +
1167
+ 'Add the mosaic source feed to pull it once.');
1168
+ }
1169
+ for (const m of members) {
1170
+ out.push({ feed: m, camera: rtspFactory(m) });
1171
+ }
1172
+ }
1173
+ return out;
1174
+ }
1175
+ /**
1176
+ * Flatten an EdgeAgentConfig into the list of feeds to run. Multi-channel
1177
+ * `cameras[]` wins; otherwise the single `camera` (+ top-level token) becomes
1178
+ * a one-element list (back-compat). Feeds without both a token and rtspUrl are
1179
+ * skipped (the single-camera fallback needs both for cloud sensing).
1180
+ */
1181
+ function resolveFeeds(config) {
1182
+ // Cloud-sense always ships JPEG (ffmpeg encodes it → no image library).
1183
+ const jpegQuality = config.cloudSensing?.jpegQuality;
1184
+ const inheritedFfmpegPath = (0, runtimePaths_1.resolveFfmpegBinaryPath)(config.camera?.ffmpegPath);
1185
+ if (Array.isArray(config.cameras) && config.cameras.length > 0) {
1186
+ // Mosaic tiles inherit their rtspUrl from the source feed (matched by id).
1187
+ // Defensive: resolveConfig already does this, but resolveFeeds may run on a
1188
+ // raw config, so resolve inheritance here too.
1189
+ const idToRtspUrl = new Map();
1190
+ for (const f of config.cameras) {
1191
+ if (f && f.id && f.rtspUrl)
1192
+ idToRtspUrl.set(f.id, f.rtspUrl);
1193
+ }
1194
+ return config.cameras
1195
+ .map((feed, i) => ({
1196
+ feed,
1197
+ i,
1198
+ rtspUrl: feed && feed.rtspUrl
1199
+ ? feed.rtspUrl
1200
+ : feed && feed.sourceFeedId
1201
+ ? idToRtspUrl.get(feed.sourceFeedId) || ''
1202
+ : '',
1203
+ }))
1204
+ // Skip creds-stay-on-prem placeholders (no rtspUrl, no resolvable source)
1205
+ // rather than hand the RTSP provider an empty URL. Warn so the operator
1206
+ // knows which feeds are idle.
1207
+ .filter(({ feed, i, rtspUrl }) => {
1208
+ if (rtspUrl)
1209
+ return true;
1210
+ // eslint-disable-next-line no-console
1211
+ console.warn(`cloud-sense: camera "${(feed && feed.name) || `camera-${i + 1}`}" has no rtspUrl — skipping. ` +
1212
+ 'Add its rtspUrl to the config (credentials stay on this machine), then restart.');
1213
+ return false;
1214
+ })
1215
+ .map(({ feed, i, rtspUrl }) => ({
1216
+ name: feed.name || `camera-${i + 1}`,
1217
+ id: feed.id,
1218
+ screenId: feed.screenId,
1219
+ deviceToken: feed.deviceToken,
1220
+ rtspUrl,
1221
+ sourceType: feed.sourceType,
1222
+ channelKey: feed.channelKey,
1223
+ recorder: feed.recorder,
1224
+ width: feed.width,
1225
+ height: feed.height,
1226
+ fps: feed.fps,
1227
+ transport: feed.transport,
1228
+ ffmpegPath: (0, runtimePaths_1.resolveFfmpegBinaryPath)(feed.ffmpegPath, inheritedFfmpegPath),
1229
+ sourceFeedId: feed.sourceFeedId,
1230
+ crop: feed.tile,
1231
+ outputFormat: 'jpeg',
1232
+ jpegQuality,
1233
+ }));
1234
+ }
1235
+ if (config.camera?.rtspUrl && config.deviceToken) {
1236
+ return [
1237
+ {
1238
+ name: config.screenId || 'camera-1',
1239
+ screenId: config.screenId,
1240
+ deviceToken: config.deviceToken,
1241
+ rtspUrl: config.camera.rtspUrl,
1242
+ width: config.camera.width,
1243
+ height: config.camera.height,
1244
+ fps: config.camera.fps,
1245
+ transport: config.camera.transport,
1246
+ ffmpegPath: inheritedFfmpegPath,
1247
+ outputFormat: 'jpeg',
1248
+ jpegQuality,
1249
+ },
1250
+ ];
1251
+ }
1252
+ return [];
1253
+ }
1254
+ //# sourceMappingURL=CloudSensingManager.js.map