@trillboards/edge-sdk 0.2.4 → 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.
- package/README.md +4 -3
- package/deploy/docker/Dockerfile.cloud-sense +117 -0
- package/deploy/docker/Dockerfile.cpu +30 -21
- package/deploy/docker/Dockerfile.cuda +30 -21
- package/deploy/docker/Dockerfile.openvino +30 -20
- package/deploy/docker/README.md +9 -4
- package/deploy/docker/connector-entrypoint.sh +122 -0
- package/deploy/helm/README.md +1 -1
- package/deploy/k8s/daemonset.yaml +1 -1
- package/deploy/macos/README.md +161 -0
- package/deploy/macos/TrillboardsSetupApp.m +603 -0
- package/deploy/macos/build-macos-pkg.sh +588 -0
- package/deploy/macos/com.trillboards.edge.plist +62 -0
- package/deploy/macos/com.trillboards.litertlm.plist +45 -0
- package/deploy/macos/pair.command +519 -0
- package/deploy/macos/scripts/ensure-litertlm.sh +62 -0
- package/deploy/macos/scripts/postinstall +153 -0
- package/deploy/macos/scripts/setup-litertlm.sh +272 -0
- package/deploy/macos/test-install-pair.sh +383 -0
- package/deploy/macos/test-setup-litertlm-reinstall.sh +109 -0
- package/deploy/systemd/install.sh +25 -15
- package/deploy/systemd/trillboards-edge.service +4 -1
- package/deploy/windows/README.md +70 -0
- package/deploy/windows/build-windows.ps1 +225 -0
- package/deploy/windows/bundle-windows-installer.ps1 +153 -0
- package/deploy/windows/check-feeds.js +26 -0
- package/deploy/windows/e2e/fixtures/person.jpg +0 -0
- package/deploy/windows/e2e/test-local-sense.ps1 +185 -0
- package/deploy/windows/install-update.ps1 +101 -0
- package/deploy/windows/installer.nsi +138 -0
- package/deploy/windows/register-litertlm-task.ps1 +42 -0
- package/deploy/windows/register-task.ps1 +44 -0
- package/deploy/windows/run-connector.ps1 +69 -0
- package/deploy/windows/setup-litertlm.ps1 +361 -0
- package/deploy/windows/stop-existing.ps1 +72 -0
- package/deploy/windows/trillboards-edge-task.vbs +25 -0
- package/deploy/windows/trillboards-edge.cmd +33 -0
- package/deploy/windows/trillboards-pair.cmd +276 -0
- package/deploy/windows/trillboards-setup.ps1 +456 -0
- package/deploy/windows/trillboards-setup.vbs +13 -0
- package/dist/AudienceAnalyzeClient.d.ts +136 -0
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -0
- package/dist/AudienceAnalyzeClient.js +246 -0
- package/dist/AudienceAnalyzeClient.js.map +1 -0
- package/dist/ChannelProber.d.ts +150 -0
- package/dist/ChannelProber.d.ts.map +1 -0
- package/dist/ChannelProber.js +887 -0
- package/dist/ChannelProber.js.map +1 -0
- package/dist/CloudEscalationPolicy.d.ts +143 -0
- package/dist/CloudEscalationPolicy.d.ts.map +1 -0
- package/dist/CloudEscalationPolicy.js +137 -0
- package/dist/CloudEscalationPolicy.js.map +1 -0
- package/dist/CloudSensingManager.d.ts +533 -0
- package/dist/CloudSensingManager.d.ts.map +1 -0
- package/dist/CloudSensingManager.js +1254 -0
- package/dist/CloudSensingManager.js.map +1 -0
- package/dist/DiscoveryReportClient.d.ts +66 -0
- package/dist/DiscoveryReportClient.d.ts.map +1 -0
- package/dist/DiscoveryReportClient.js +85 -0
- package/dist/DiscoveryReportClient.js.map +1 -0
- package/dist/EdgeAgent.d.ts +12 -2
- package/dist/EdgeAgent.d.ts.map +1 -1
- package/dist/EdgeAgent.js +55 -13
- package/dist/EdgeAgent.js.map +1 -1
- package/dist/LiteRtVlmProvider.d.ts +193 -0
- package/dist/LiteRtVlmProvider.d.ts.map +1 -0
- package/dist/LiteRtVlmProvider.js +436 -0
- package/dist/LiteRtVlmProvider.js.map +1 -0
- package/dist/MosaicSource.d.ts +108 -0
- package/dist/MosaicSource.d.ts.map +1 -0
- package/dist/MosaicSource.js +274 -0
- package/dist/MosaicSource.js.map +1 -0
- package/dist/OnvifClient.d.ts +83 -0
- package/dist/OnvifClient.d.ts.map +1 -0
- package/dist/OnvifClient.js +209 -0
- package/dist/OnvifClient.js.map +1 -0
- package/dist/PairClient.d.ts +106 -0
- package/dist/PairClient.d.ts.map +1 -0
- package/dist/PairClient.js +186 -0
- package/dist/PairClient.js.map +1 -0
- package/dist/RecorderAddressRecovery.d.ts +53 -0
- package/dist/RecorderAddressRecovery.d.ts.map +1 -0
- package/dist/RecorderAddressRecovery.js +359 -0
- package/dist/RecorderAddressRecovery.js.map +1 -0
- package/dist/SceneChangeDetector.d.ts +291 -0
- package/dist/SceneChangeDetector.d.ts.map +1 -0
- package/dist/SceneChangeDetector.js +682 -0
- package/dist/SceneChangeDetector.js.map +1 -0
- package/dist/SensingProfileClient.d.ts +59 -0
- package/dist/SensingProfileClient.d.ts.map +1 -0
- package/dist/SensingProfileClient.js +78 -0
- package/dist/SensingProfileClient.js.map +1 -0
- package/dist/SetupSessionClient.d.ts +114 -0
- package/dist/SetupSessionClient.d.ts.map +1 -0
- package/dist/SetupSessionClient.js +95 -0
- package/dist/SetupSessionClient.js.map +1 -0
- package/dist/VlmAdmissionGate.d.ts +126 -0
- package/dist/VlmAdmissionGate.d.ts.map +1 -0
- package/dist/VlmAdmissionGate.js +166 -0
- package/dist/VlmAdmissionGate.js.map +1 -0
- package/dist/autoSense.d.ts +216 -0
- package/dist/autoSense.d.ts.map +1 -0
- package/dist/autoSense.js +521 -0
- package/dist/autoSense.js.map +1 -0
- package/dist/cli.d.ts +27 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +511 -21
- package/dist/cli.js.map +1 -1
- package/dist/combinedSense.d.ts +128 -0
- package/dist/combinedSense.d.ts.map +1 -0
- package/dist/combinedSense.js +261 -0
- package/dist/combinedSense.js.map +1 -0
- package/dist/config.d.ts +351 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +99 -2
- package/dist/config.js.map +1 -1
- package/dist/connectorCadence.d.ts +135 -0
- package/dist/connectorCadence.d.ts.map +1 -0
- package/dist/connectorCadence.js +151 -0
- package/dist/connectorCadence.js.map +1 -0
- package/dist/dataDir.d.ts +33 -0
- package/dist/dataDir.d.ts.map +1 -0
- package/dist/dataDir.js +76 -0
- package/dist/dataDir.js.map +1 -0
- package/dist/deployedCadence.d.ts +167 -0
- package/dist/deployedCadence.d.ts.map +1 -0
- package/dist/deployedCadence.js +209 -0
- package/dist/deployedCadence.js.map +1 -0
- package/dist/deployedCadenceSource.d.ts +48 -0
- package/dist/deployedCadenceSource.d.ts.map +1 -0
- package/dist/deployedCadenceSource.js +74 -0
- package/dist/deployedCadenceSource.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.d.ts.map +1 -1
- package/dist/diagnose.js +13 -12
- package/dist/diagnose.js.map +1 -1
- package/dist/discovery.d.ts +131 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +499 -0
- package/dist/discovery.js.map +1 -0
- package/dist/gemmaModelOta.d.ts +118 -0
- package/dist/gemmaModelOta.d.ts.map +1 -0
- package/dist/gemmaModelOta.js +291 -0
- package/dist/gemmaModelOta.js.map +1 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +41 -18
- package/dist/init.js.map +1 -1
- package/dist/keepAwake.d.ts +33 -0
- package/dist/keepAwake.d.ts.map +1 -0
- package/dist/keepAwake.js +54 -0
- package/dist/keepAwake.js.map +1 -0
- package/dist/legacyRecorderProfile.d.ts +20 -0
- package/dist/legacyRecorderProfile.d.ts.map +1 -0
- package/dist/legacyRecorderProfile.js +27 -0
- package/dist/legacyRecorderProfile.js.map +1 -0
- package/dist/localInferenceSchema.d.ts +132 -0
- package/dist/localInferenceSchema.d.ts.map +1 -0
- package/dist/localInferenceSchema.js +274 -0
- package/dist/localInferenceSchema.js.map +1 -0
- package/dist/localSenseE2eHarness.d.ts +101 -0
- package/dist/localSenseE2eHarness.d.ts.map +1 -0
- package/dist/localSenseE2eHarness.js +279 -0
- package/dist/localSenseE2eHarness.js.map +1 -0
- package/dist/localSensing.d.ts +268 -0
- package/dist/localSensing.d.ts.map +1 -0
- package/dist/localSensing.js +492 -0
- package/dist/localSensing.js.map +1 -0
- package/dist/modelIntegrity.d.ts +59 -0
- package/dist/modelIntegrity.d.ts.map +1 -0
- package/dist/modelIntegrity.js +165 -0
- package/dist/modelIntegrity.js.map +1 -0
- package/dist/pairVerify.d.ts +100 -0
- package/dist/pairVerify.d.ts.map +1 -0
- package/dist/pairVerify.js +296 -0
- package/dist/pairVerify.js.map +1 -0
- package/dist/qrScanner.d.ts +33 -0
- package/dist/qrScanner.d.ts.map +1 -0
- package/dist/qrScanner.js +103 -0
- package/dist/qrScanner.js.map +1 -0
- package/dist/runtimePaths.d.ts +10 -0
- package/dist/runtimePaths.d.ts.map +1 -0
- package/dist/runtimePaths.js +79 -0
- package/dist/runtimePaths.js.map +1 -0
- package/dist/sensingRunMode.d.ts +68 -0
- package/dist/sensingRunMode.d.ts.map +1 -0
- package/dist/sensingRunMode.js +346 -0
- package/dist/sensingRunMode.js.map +1 -0
- package/dist/setupMode.d.ts +98 -0
- package/dist/setupMode.d.ts.map +1 -0
- package/dist/setupMode.js +828 -0
- package/dist/setupMode.js.map +1 -0
- package/dist/setupProgress.d.ts +31 -0
- package/dist/setupProgress.d.ts.map +1 -0
- package/dist/setupProgress.js +173 -0
- package/dist/setupProgress.js.map +1 -0
- 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
|