@camstack/addon-pipeline-orchestrator 1.2.167 → 1.2.168
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/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-Cd0R035D.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-C5V3S9Pp.mjs} +3 -3
- package/dist/{hostInit-BVIqHvsT.mjs → hostInit-cWPH1Lu7.mjs} +3 -3
- package/dist/index.js +1435 -1174
- package/dist/index.mjs +1435 -1174
- package/dist/remoteEntry.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -21310,8 +21310,25 @@ var occupancyRecheckFramesField = {
|
|
|
21310
21310
|
* (analyzer attaches detected `regions[]`; onboard does not — the
|
|
21311
21311
|
* camera typically only reports a binary signal plus an optional
|
|
21312
21312
|
* channel/AI class which lives in dedicated event channels).
|
|
21313
|
-
|
|
21314
|
-
|
|
21313
|
+
*
|
|
21314
|
+
* - `onboard` — the camera's firmware said something moved.
|
|
21315
|
+
* - `analyzer` — this runner's frame-diff said so, and attaches `regions[]`.
|
|
21316
|
+
* - `device-activity` — the DEVICE said it is doing its job: the
|
|
21317
|
+
* `recording-signal` LEVEL the same device raises for the recorder
|
|
21318
|
+
* ([D380](../../../../docs/decisions/adr-0380-a-device-decided-recording-is-a-mode-with-no-schedule-seeded-once.md)),
|
|
21319
|
+
* republished as a motion source. It attaches **nothing** — no regions, no
|
|
21320
|
+
* class: the only fact it carries is that the device is active, and a robot
|
|
21321
|
+
* vacuum that is itself the moving object has no region worth sending. It is
|
|
21322
|
+
* a LEVEL, so unlike `onboard` it has a real falling edge, and unlike
|
|
21323
|
+
* `analyzer` it must not open the frame-diff side-channel — the runner's
|
|
21324
|
+
* `handleOnboardMotionAnalyzer` gate is `source === 'onboard'` and stays that
|
|
21325
|
+
* way ([D392](../../../../docs/decisions/adr-0392-a-device-that-says-it-is-working-is-a-motion-source-of-its-own.md)).
|
|
21326
|
+
*/
|
|
21327
|
+
var MotionSourceEnum = _enum([
|
|
21328
|
+
"onboard",
|
|
21329
|
+
"analyzer",
|
|
21330
|
+
"device-activity"
|
|
21331
|
+
]);
|
|
21315
21332
|
/**
|
|
21316
21333
|
* List of motion sources active on a camera. Empty array is valid:
|
|
21317
21334
|
* "no source" — happens for battery cams without firmware motion when
|
|
@@ -21575,13 +21592,20 @@ var RunnerCameraDeviceUIFields = [
|
|
|
21575
21592
|
type: "multiselect",
|
|
21576
21593
|
label: "Motion Sources",
|
|
21577
21594
|
default: ["analyzer"],
|
|
21578
|
-
options: [
|
|
21579
|
-
|
|
21580
|
-
|
|
21581
|
-
|
|
21582
|
-
|
|
21583
|
-
|
|
21584
|
-
|
|
21595
|
+
options: [
|
|
21596
|
+
{
|
|
21597
|
+
value: "analyzer",
|
|
21598
|
+
label: "Frame-diff Analyzer (motion addon)"
|
|
21599
|
+
},
|
|
21600
|
+
{
|
|
21601
|
+
value: "onboard",
|
|
21602
|
+
label: "Camera Onboard Sensor"
|
|
21603
|
+
},
|
|
21604
|
+
{
|
|
21605
|
+
value: "device-activity",
|
|
21606
|
+
label: "Device activity (the device says it is working)"
|
|
21607
|
+
}
|
|
21608
|
+
]
|
|
21585
21609
|
},
|
|
21586
21610
|
{
|
|
21587
21611
|
key: "motionFps",
|
|
@@ -29578,8 +29602,38 @@ var RecordingSignalStatusSchema = object({
|
|
|
29578
29602
|
/** Ms epoch of the last `active` transition. 0 if never observed. */
|
|
29579
29603
|
lastChangedAt: number()
|
|
29580
29604
|
});
|
|
29581
|
-
|
|
29582
|
-
|
|
29605
|
+
/** The runtime-state slice: the status plus the clock every slice carries. */
|
|
29606
|
+
var RecordingSignalRuntimeStateSchema = RecordingSignalStatusSchema.extend({ lastFetchedAt: number() });
|
|
29607
|
+
var recordingSignalCapability = {
|
|
29608
|
+
name: "recording-signal",
|
|
29609
|
+
scope: "device",
|
|
29610
|
+
deviceNative: true,
|
|
29611
|
+
mode: "singleton",
|
|
29612
|
+
deviceTypes: [DeviceType.Camera],
|
|
29613
|
+
methods: {
|
|
29614
|
+
/** The current level, straight from the slice the provider keeps fresh. */
|
|
29615
|
+
getStatus: method(object({ deviceId: number() }), RecordingSignalStatusSchema) },
|
|
29616
|
+
status: {
|
|
29617
|
+
schema: RecordingSignalStatusSchema,
|
|
29618
|
+
kind: "push",
|
|
29619
|
+
empty: {
|
|
29620
|
+
active: false,
|
|
29621
|
+
reason: "unknown",
|
|
29622
|
+
lastChangedAt: 0
|
|
29623
|
+
}
|
|
29624
|
+
},
|
|
29625
|
+
runtimeState: RecordingSignalRuntimeStateSchema,
|
|
29626
|
+
/**
|
|
29627
|
+
* Runtime-state durability: **session** — a restored `active: true` from
|
|
29628
|
+
* before a restart is exactly the stale level the recorder's reconcile bound
|
|
29629
|
+
* exists to end, and the provider re-derives the true level on activation
|
|
29630
|
+
* anyway. Nothing is lost by forgetting it; a lie is avoided.
|
|
29631
|
+
*
|
|
29632
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
29633
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
29634
|
+
*/
|
|
29635
|
+
durability: "session"
|
|
29636
|
+
};
|
|
29583
29637
|
/**
|
|
29584
29638
|
* scene-monitor — device-scoped reference-region state cap. An operator marks
|
|
29585
29639
|
* a rect ROI on a camera frame and names one or more states; the engine
|
|
@@ -40055,477 +40109,278 @@ function deviceBackendToFormat(backend) {
|
|
|
40055
40109
|
return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
|
|
40056
40110
|
}
|
|
40057
40111
|
//#endregion
|
|
40058
|
-
//#region src/
|
|
40112
|
+
//#region src/audio-chunk-poller.ts
|
|
40059
40113
|
/**
|
|
40060
|
-
*
|
|
40061
|
-
*
|
|
40114
|
+
* `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
|
|
40115
|
+
* plane (Phase 5 / D9).
|
|
40062
40116
|
*
|
|
40063
|
-
*
|
|
40064
|
-
*
|
|
40065
|
-
*
|
|
40066
|
-
*
|
|
40067
|
-
* and the detection-pipeline are separate addons; only tRPC crosses the
|
|
40068
|
-
* boundary). Keep this in sync with `default-detection-model.ts` /
|
|
40069
|
-
* `step-definitions.ts` if the executor's defaults change.
|
|
40117
|
+
* Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
|
|
40118
|
+
* path. A live callback cannot cross a process boundary; once the `pipeline`
|
|
40119
|
+
* group is dissolved (Task 8) the orchestrator runs in a different process
|
|
40120
|
+
* from the broker, so audio delivery must go over tRPC.
|
|
40070
40121
|
*
|
|
40071
|
-
* The
|
|
40072
|
-
* - `yolov9m-320-int8` — Intel NPU + iGPU (yolo26 does NOT compile on the NPU)
|
|
40073
|
-
* - `yolov9m-320` — Apple ANE (CoreML)
|
|
40074
|
-
* - `ssd-mobilenet-v2-coco-edgetpu` — Coral USB Edge TPU (tflite)
|
|
40075
|
-
* - `yolo26n` — CPU / CUDA (the object-detection step's universal
|
|
40076
|
-
* nano default)
|
|
40122
|
+
* The consumer:
|
|
40077
40123
|
*
|
|
40078
|
-
*
|
|
40079
|
-
*
|
|
40080
|
-
*
|
|
40124
|
+
* 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
|
|
40125
|
+
* registers a per-subscription bounded FIFO queue and returns a
|
|
40126
|
+
* `subscriptionId`;
|
|
40127
|
+
* 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
|
|
40128
|
+
* interval, draining `DecodedAudioChunk`s in FIFO arrival order;
|
|
40129
|
+
* 3. feeds each chunk to its downstream audio logic;
|
|
40130
|
+
* 4. on teardown, `unsubscribeAudioChunks`.
|
|
40131
|
+
*
|
|
40132
|
+
* Audio is not latency-critical like video, and chunks arrive only ~every
|
|
40133
|
+
* 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
|
|
40134
|
+
* a small per-poll burst keeps latency low without busy-spinning. The
|
|
40135
|
+
* broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
|
|
40136
|
+
* loses a chunk.
|
|
40137
|
+
*
|
|
40138
|
+
* Boot-race tolerance: the broker for a given camStream may not be registered
|
|
40139
|
+
* yet when the orchestrator wires the subscription (provider addons publish
|
|
40140
|
+
* their cameraStreams asynchronously after their probe completes).
|
|
40141
|
+
* `subscribeAudioChunks` retries with exponential backoff (capped at
|
|
40142
|
+
* `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
|
|
40143
|
+
* teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
|
|
40144
|
+
* shape so video and audio plumbing self-heal identically.
|
|
40081
40145
|
*/
|
|
40146
|
+
/** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
|
|
40147
|
+
var POLL_INTERVAL_MS$1 = 200;
|
|
40148
|
+
/** How many chunks to drain per poll — a small burst absorbs jitter. */
|
|
40149
|
+
var PULL_MAX_COUNT = 8;
|
|
40082
40150
|
/**
|
|
40083
|
-
*
|
|
40084
|
-
*
|
|
40085
|
-
*
|
|
40086
|
-
*
|
|
40087
|
-
* defaults above).
|
|
40151
|
+
* Consecutive pull failures before we attempt to re-subscribe. A single failed
|
|
40152
|
+
* poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
|
|
40153
|
+
* sustained failure means the broker child restarted and dropped our
|
|
40154
|
+
* subscription, so we re-establish it.
|
|
40088
40155
|
*/
|
|
40089
|
-
var
|
|
40156
|
+
var RESUBSCRIBE_AFTER_FAILURES = 2;
|
|
40157
|
+
/** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
|
|
40158
|
+
var RESUBSCRIBE_THROTTLE_TICKS = 5;
|
|
40159
|
+
/** First subscribe-retry delay, doubled on every subsequent failure. */
|
|
40160
|
+
var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
|
|
40090
40161
|
/**
|
|
40091
|
-
*
|
|
40092
|
-
*
|
|
40093
|
-
*
|
|
40094
|
-
* format — byte-for-byte the resolver's per-device skip-gate test for the root
|
|
40095
|
-
* step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
|
|
40096
|
-
* would ACTUALLY provision on it.
|
|
40097
|
-
*
|
|
40098
|
-
* Fails OPEN when the catalog has no object-detection slot at all (never
|
|
40099
|
-
* observed in production) so a malformed/empty catalog never strands every
|
|
40100
|
-
* device off the balancer. Pure + deterministic.
|
|
40162
|
+
* Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
|
|
40163
|
+
* enough to recover within a single reconcile of the orchestrator and slow
|
|
40164
|
+
* enough that a misconfigured camStream costs ~12 op/min, not 5/s.
|
|
40101
40165
|
*/
|
|
40102
|
-
|
|
40103
|
-
for (const slot of catalog.slots) {
|
|
40104
|
-
const objDet = slot.addons.find((a) => a.id === OBJECT_DETECTION_STEP_ID);
|
|
40105
|
-
if (objDet) return (format) => objDet.models.some((m) => Boolean(m.formats[format]));
|
|
40106
|
-
}
|
|
40107
|
-
return () => true;
|
|
40108
|
-
}
|
|
40109
|
-
/** Split a deviceKey (`<backend>:<device>`, or bare `cpu`) into its parts + format.
|
|
40110
|
-
* Format comes from the shared {@link deviceBackendToFormat} SSOT (`@camstack/types`)
|
|
40111
|
-
* — the previously-local `BACKEND_FORMAT` copy is gone (R3/node-F2). Used only for
|
|
40112
|
-
* STORED-ONLY keys (a configured device the live probe didn't return); a probed
|
|
40113
|
-
* device carries its own honest `format` from the descriptor. */
|
|
40114
|
-
function parseDeviceKey(deviceKey) {
|
|
40115
|
-
const colon = deviceKey.indexOf(":");
|
|
40116
|
-
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
40117
|
-
return {
|
|
40118
|
-
backend,
|
|
40119
|
-
device: colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey,
|
|
40120
|
-
format: deviceBackendToFormat(backend)
|
|
40121
|
-
};
|
|
40122
|
-
}
|
|
40166
|
+
var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
|
|
40123
40167
|
/**
|
|
40124
|
-
*
|
|
40125
|
-
*
|
|
40126
|
-
*
|
|
40127
|
-
*
|
|
40168
|
+
* Attempts after which a still-failing subscribe escalates from the fast 5 s
|
|
40169
|
+
* ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
|
|
40170
|
+
* minute of fast retries — plenty for the boot races the 5 s ceiling exists
|
|
40171
|
+
* for. A broker that is STILL absent after that is a long-lived condition
|
|
40172
|
+
* (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
|
|
40173
|
+
* to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
|
|
40174
|
+
* churn. The slow loop stays alive so audio still recovers automatically
|
|
40175
|
+
* (≤60 s) once the camera is re-enabled.
|
|
40128
40176
|
*/
|
|
40129
|
-
|
|
40130
|
-
|
|
40131
|
-
if (backend === "openvino") {
|
|
40132
|
-
if (device === "cpu") return "yolo26n";
|
|
40133
|
-
return "yolov9m-320-int8";
|
|
40134
|
-
}
|
|
40135
|
-
if (backend === "edgetpu") return "ssd-mobilenet-v2-coco-edgetpu";
|
|
40136
|
-
if (backend === "coreml") return "yolov9m-320";
|
|
40137
|
-
return "yolo26n";
|
|
40138
|
-
}
|
|
40177
|
+
var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
|
|
40178
|
+
var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
|
|
40139
40179
|
/**
|
|
40140
|
-
*
|
|
40141
|
-
*
|
|
40142
|
-
*
|
|
40143
|
-
*
|
|
40144
|
-
*
|
|
40145
|
-
*
|
|
40146
|
-
* Returns the FIRST human-readable error, or `null` when every override is
|
|
40147
|
-
* valid. Pure + deterministic.
|
|
40180
|
+
* Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
|
|
40181
|
+
*
|
|
40182
|
+
* Always resolves to a teardown closure — when the broker is not yet
|
|
40183
|
+
* registered the closure cancels the ongoing retry loop; when polling is
|
|
40184
|
+
* active it stops the loop and releases the broker subscription. Mirrors
|
|
40185
|
+
* `startFrameHandlePoller` so video and audio recover identically.
|
|
40148
40186
|
*/
|
|
40149
|
-
function
|
|
40150
|
-
|
|
40151
|
-
|
|
40152
|
-
|
|
40153
|
-
|
|
40154
|
-
|
|
40155
|
-
}
|
|
40156
|
-
|
|
40187
|
+
function startAudioChunkPoller(options) {
|
|
40188
|
+
const lifecycle = {
|
|
40189
|
+
stopped: false,
|
|
40190
|
+
retryTimer: void 0,
|
|
40191
|
+
pollTimer: void 0,
|
|
40192
|
+
activeSubscriptionId: null
|
|
40193
|
+
};
|
|
40194
|
+
const teardown = () => {
|
|
40195
|
+
if (lifecycle.stopped) return;
|
|
40196
|
+
lifecycle.stopped = true;
|
|
40197
|
+
if (lifecycle.retryTimer) {
|
|
40198
|
+
clearTimeout(lifecycle.retryTimer);
|
|
40199
|
+
lifecycle.retryTimer = void 0;
|
|
40200
|
+
}
|
|
40201
|
+
if (lifecycle.pollTimer) {
|
|
40202
|
+
clearTimeout(lifecycle.pollTimer);
|
|
40203
|
+
lifecycle.pollTimer = void 0;
|
|
40204
|
+
}
|
|
40205
|
+
const subId = lifecycle.activeSubscriptionId;
|
|
40206
|
+
if (subId) {
|
|
40207
|
+
lifecycle.activeSubscriptionId = null;
|
|
40208
|
+
options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
|
|
40209
|
+
options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
|
|
40210
|
+
brokerId: options.brokerId,
|
|
40211
|
+
subscriptionId: subId,
|
|
40212
|
+
error: errMsg(err)
|
|
40213
|
+
} });
|
|
40214
|
+
});
|
|
40215
|
+
}
|
|
40216
|
+
};
|
|
40217
|
+
subscribeWithRetry(options, lifecycle);
|
|
40218
|
+
return teardown;
|
|
40157
40219
|
}
|
|
40158
40220
|
/**
|
|
40159
|
-
*
|
|
40160
|
-
*
|
|
40161
|
-
*
|
|
40162
|
-
* opt-OUT) with TWO deliberate exceptions, both **opt-IN** (default disabled):
|
|
40163
|
-
*
|
|
40164
|
-
* - **CPU**: `enumerateInferenceDevices` always emits a universal `cpu`
|
|
40165
|
-
* floor on every platform; auto-enabling it would let the balancer
|
|
40166
|
-
* round-robin ~1/N of sessions onto the slow CPU pool alongside the
|
|
40167
|
-
* NPU/iGPU/ANE. CPU stays the always-available FALLBACK (a node with no
|
|
40168
|
-
* eligible accelerator leaves `deviceKey` unset → the runner's default
|
|
40169
|
-
* pool, which is CPU), not a balanced target — matching the spec's "no
|
|
40170
|
-
* device eligible → fall back to CPU".
|
|
40171
|
-
* - **Coral Edge TPU (`edgetpu`)**: the standing rule since the Coral
|
|
40172
|
-
* executor landed is that it surfaces as selectable but is NEVER
|
|
40173
|
-
* auto-picked — it runs a DIFFERENT, weaker model family (tflite SSD
|
|
40174
|
-
* MobileNet, not the YOLO the other accelerators run), so silently
|
|
40175
|
-
* enrolling a plugged-in Coral changes detection QUALITY, not just
|
|
40176
|
-
* placement. The opt-OUT default did exactly that on 2026-08-01: a hub
|
|
40177
|
-
* Coral nobody enabled entered the session rotation and camera 615 spent
|
|
40178
|
-
* hours at 2.4fps failing tflite model resolution. An operator who wants
|
|
40179
|
-
* the Coral balanced opts it in explicitly (`enabled: true`).
|
|
40180
|
-
*
|
|
40181
|
-
* So: an NPU/iGPU/ANE accelerator with NO stored entry is `enabled:true`; a
|
|
40182
|
-
* CPU or edgetpu device with no stored entry is `enabled:false`; an explicit
|
|
40183
|
-
* stored `enabled` always wins (an operator can opt CPU/Coral in, or an
|
|
40184
|
-
* accelerator out). A stored-only key (configured but the probe did not
|
|
40185
|
-
* return it — removed/unplugged HW) keeps its stored `enabled` and surfaces
|
|
40186
|
-
* as `available:false`, so the UI still shows it.
|
|
40187
|
-
*
|
|
40188
|
-
* Pure + deterministic (sorted by key) — the single merge authority shared by
|
|
40189
|
-
* the `getNodeInferenceDevices` view and the dispatcher's eligible-device pick.
|
|
40221
|
+
* Run the subscribe → poll handshake with exponential backoff on subscribe
|
|
40222
|
+
* failures. Resolves once the subscription is acquired (and the poll loop has
|
|
40223
|
+
* been started) or once `lifecycle.stopped` flips, whichever comes first.
|
|
40190
40224
|
*/
|
|
40191
|
-
function
|
|
40192
|
-
const
|
|
40193
|
-
const
|
|
40194
|
-
|
|
40195
|
-
|
|
40196
|
-
|
|
40197
|
-
|
|
40198
|
-
|
|
40199
|
-
|
|
40200
|
-
|
|
40201
|
-
|
|
40202
|
-
|
|
40203
|
-
|
|
40204
|
-
|
|
40205
|
-
|
|
40206
|
-
|
|
40207
|
-
|
|
40208
|
-
|
|
40209
|
-
|
|
40210
|
-
|
|
40211
|
-
|
|
40212
|
-
|
|
40225
|
+
async function subscribeWithRetry(options, lifecycle) {
|
|
40226
|
+
const { api, brokerId, tag, ownerNodeId, logger } = options;
|
|
40227
|
+
const pin = nodePin(ownerNodeId);
|
|
40228
|
+
let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
|
|
40229
|
+
let attempt = 0;
|
|
40230
|
+
while (!lifecycle.stopped) {
|
|
40231
|
+
attempt += 1;
|
|
40232
|
+
try {
|
|
40233
|
+
const result = await api.streamBroker.subscribeAudioChunks.mutate({
|
|
40234
|
+
brokerId,
|
|
40235
|
+
tag
|
|
40236
|
+
}, pin);
|
|
40237
|
+
if (lifecycle.stopped) {
|
|
40238
|
+
await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
|
|
40239
|
+
logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
|
|
40240
|
+
brokerId,
|
|
40241
|
+
subscriptionId: result.subscriptionId,
|
|
40242
|
+
error: errMsg(err)
|
|
40243
|
+
} });
|
|
40244
|
+
});
|
|
40245
|
+
return;
|
|
40246
|
+
}
|
|
40247
|
+
if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
|
|
40248
|
+
brokerId,
|
|
40249
|
+
tag,
|
|
40250
|
+
attempt
|
|
40251
|
+
} });
|
|
40252
|
+
lifecycle.activeSubscriptionId = result.subscriptionId;
|
|
40253
|
+
startPolling(options, lifecycle);
|
|
40254
|
+
return;
|
|
40255
|
+
} catch (err) {
|
|
40256
|
+
if (lifecycle.stopped) return;
|
|
40257
|
+
if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
|
|
40258
|
+
brokerId,
|
|
40259
|
+
tag,
|
|
40260
|
+
error: errMsg(err),
|
|
40261
|
+
nextRetryInMs: backoffMs
|
|
40262
|
+
} });
|
|
40263
|
+
else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
|
|
40264
|
+
brokerId,
|
|
40265
|
+
tag,
|
|
40266
|
+
attempt,
|
|
40267
|
+
error: errMsg(err),
|
|
40268
|
+
nextRetryInMs: backoffMs
|
|
40269
|
+
} });
|
|
40270
|
+
await sleep(backoffMs, lifecycle);
|
|
40271
|
+
backoffMs = Math.min(attempt >= PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS ? LONG_SUBSCRIBE_RETRY_BACKOFF_MS : MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
|
|
40272
|
+
}
|
|
40213
40273
|
}
|
|
40214
|
-
return out;
|
|
40215
40274
|
}
|
|
40216
40275
|
/**
|
|
40217
|
-
*
|
|
40218
|
-
*
|
|
40219
|
-
*
|
|
40276
|
+
* Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
|
|
40277
|
+
* trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
|
|
40278
|
+
* the broker child restart case where our `subscriptionId` is silently
|
|
40279
|
+
* disowned.
|
|
40220
40280
|
*/
|
|
40221
|
-
function
|
|
40222
|
-
const
|
|
40223
|
-
|
|
40224
|
-
|
|
40225
|
-
|
|
40226
|
-
|
|
40227
|
-
|
|
40228
|
-
|
|
40229
|
-
|
|
40230
|
-
|
|
40231
|
-
|
|
40232
|
-
|
|
40233
|
-
|
|
40234
|
-
|
|
40235
|
-
|
|
40236
|
-
|
|
40237
|
-
|
|
40238
|
-
|
|
40239
|
-
|
|
40240
|
-
|
|
40241
|
-
});
|
|
40242
|
-
continue;
|
|
40243
|
-
}
|
|
40244
|
-
if (!d.available) {
|
|
40245
|
-
excluded.push({
|
|
40246
|
-
key: d.key,
|
|
40247
|
-
reason: "unavailable",
|
|
40248
|
-
format: d.format
|
|
40249
|
-
});
|
|
40250
|
-
continue;
|
|
40251
|
-
}
|
|
40252
|
-
if (isPoolUsable && !isPoolUsable(d.key)) {
|
|
40253
|
-
excluded.push({
|
|
40254
|
-
key: d.key,
|
|
40255
|
-
reason: "unavailable",
|
|
40256
|
-
format: d.format
|
|
40257
|
-
});
|
|
40258
|
-
continue;
|
|
40259
|
-
}
|
|
40260
|
-
if (canRunRoot && !canRunRoot(d.format)) {
|
|
40261
|
-
excluded.push({
|
|
40262
|
-
key: d.key,
|
|
40263
|
-
reason: "cannot-host-camera-root",
|
|
40264
|
-
format: d.format
|
|
40265
|
-
});
|
|
40266
|
-
continue;
|
|
40281
|
+
function startPolling(options, lifecycle) {
|
|
40282
|
+
const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
|
|
40283
|
+
const pin = nodePin(ownerNodeId);
|
|
40284
|
+
let consecutiveFailures = 0;
|
|
40285
|
+
const resubscribe = async () => {
|
|
40286
|
+
try {
|
|
40287
|
+
const result = await api.streamBroker.subscribeAudioChunks.mutate({
|
|
40288
|
+
brokerId,
|
|
40289
|
+
tag
|
|
40290
|
+
}, pin);
|
|
40291
|
+
lifecycle.activeSubscriptionId = result.subscriptionId;
|
|
40292
|
+
logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
|
|
40293
|
+
brokerId,
|
|
40294
|
+
tag,
|
|
40295
|
+
subscriptionId: result.subscriptionId,
|
|
40296
|
+
afterFailures: consecutiveFailures
|
|
40297
|
+
} });
|
|
40298
|
+
return true;
|
|
40299
|
+
} catch {
|
|
40300
|
+
return false;
|
|
40267
40301
|
}
|
|
40268
|
-
|
|
40269
|
-
|
|
40270
|
-
|
|
40271
|
-
|
|
40272
|
-
|
|
40273
|
-
|
|
40274
|
-
|
|
40302
|
+
};
|
|
40303
|
+
const tick = async () => {
|
|
40304
|
+
if (lifecycle.stopped) return;
|
|
40305
|
+
const subId = lifecycle.activeSubscriptionId;
|
|
40306
|
+
if (!subId) return;
|
|
40307
|
+
try {
|
|
40308
|
+
const chunks = await api.streamBroker.pullAudioChunks.query({
|
|
40309
|
+
subscriptionId: subId,
|
|
40310
|
+
maxCount: PULL_MAX_COUNT
|
|
40311
|
+
}, pin);
|
|
40312
|
+
if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
|
|
40313
|
+
brokerId,
|
|
40314
|
+
subscriptionId: subId
|
|
40315
|
+
} });
|
|
40316
|
+
consecutiveFailures = 0;
|
|
40317
|
+
for (const chunk of chunks) {
|
|
40318
|
+
if (lifecycle.stopped) break;
|
|
40319
|
+
await onChunk(chunk);
|
|
40320
|
+
}
|
|
40321
|
+
} catch (err) {
|
|
40322
|
+
consecutiveFailures += 1;
|
|
40323
|
+
if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
|
|
40324
|
+
brokerId,
|
|
40325
|
+
subscriptionId: subId,
|
|
40326
|
+
error: errMsg(err)
|
|
40327
|
+
} });
|
|
40328
|
+
if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
|
|
40275
40329
|
}
|
|
40276
|
-
|
|
40277
|
-
}
|
|
40278
|
-
return {
|
|
40279
|
-
eligible,
|
|
40280
|
-
excluded
|
|
40330
|
+
if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS$1);
|
|
40281
40331
|
};
|
|
40332
|
+
tick();
|
|
40282
40333
|
}
|
|
40283
40334
|
/**
|
|
40284
|
-
*
|
|
40285
|
-
*
|
|
40286
|
-
*
|
|
40287
|
-
*
|
|
40288
|
-
*
|
|
40289
|
-
* is a node-WIDE rule (a CPU row reads `enabled:true, available:true` and still
|
|
40290
|
-
* never gets a session, D215) and `cannot-host-camera-root` needs the node's
|
|
40291
|
-
* model catalog. Both live in {@link resolveInferenceDeviceEligibility}, so this
|
|
40292
|
-
* function only transports its answer; it never re-derives one.
|
|
40293
|
-
*
|
|
40294
|
-
* Pure; preserves `merged`'s order (sorted by key) and every other field.
|
|
40335
|
+
* Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
|
|
40336
|
+
* keep a local wrapper around the shared {@link sleep} helper because
|
|
40337
|
+
* the lifecycle tracks the active retry timer for `teardown()` to
|
|
40338
|
+
* clear; pure `sleep()` would leak the timer if teardown fired while
|
|
40339
|
+
* we were waiting.
|
|
40295
40340
|
*/
|
|
40296
|
-
function
|
|
40297
|
-
|
|
40298
|
-
|
|
40299
|
-
|
|
40300
|
-
|
|
40301
|
-
|
|
40341
|
+
function sleep(ms, lifecycle) {
|
|
40342
|
+
return new Promise((resolve) => {
|
|
40343
|
+
if (lifecycle.stopped) {
|
|
40344
|
+
resolve();
|
|
40345
|
+
return;
|
|
40346
|
+
}
|
|
40347
|
+
lifecycle.retryTimer = setTimeout(() => {
|
|
40348
|
+
lifecycle.retryTimer = void 0;
|
|
40349
|
+
resolve();
|
|
40350
|
+
}, ms);
|
|
40351
|
+
});
|
|
40302
40352
|
}
|
|
40303
|
-
|
|
40304
|
-
|
|
40305
|
-
|
|
40353
|
+
//#endregion
|
|
40354
|
+
//#region src/audio-load-balancer.ts
|
|
40355
|
+
function balanceAudio(input) {
|
|
40356
|
+
if (input.nodes.length === 0) return null;
|
|
40357
|
+
if (input.preferredNode) {
|
|
40358
|
+
const pinned = input.nodes.find((n) => n.nodeId === input.preferredNode);
|
|
40359
|
+
if (pinned) return {
|
|
40360
|
+
nodeId: pinned.nodeId,
|
|
40361
|
+
reason: "manual"
|
|
40362
|
+
};
|
|
40363
|
+
}
|
|
40306
40364
|
return {
|
|
40307
|
-
|
|
40308
|
-
|
|
40309
|
-
eligibleKeys
|
|
40365
|
+
nodeId: input.nodes.slice().toSorted((a, b) => a.deviceCount - b.deviceCount)[0].nodeId,
|
|
40366
|
+
reason: "capacity"
|
|
40310
40367
|
};
|
|
40311
40368
|
}
|
|
40369
|
+
//#endregion
|
|
40370
|
+
//#region src/orchestrator-types.ts
|
|
40371
|
+
var PHASE_MODE_VALUES = new Set([
|
|
40372
|
+
"disabled",
|
|
40373
|
+
"always-on",
|
|
40374
|
+
"on-motion"
|
|
40375
|
+
]);
|
|
40376
|
+
function isPipelinePhaseMode(v) {
|
|
40377
|
+
return PHASE_MODE_VALUES.has(v);
|
|
40378
|
+
}
|
|
40312
40379
|
/**
|
|
40313
|
-
*
|
|
40314
|
-
*
|
|
40315
|
-
* `
|
|
40316
|
-
*
|
|
40317
|
-
* and `caps` (deviceKey→maxSessions) the dispatcher already computes, so the
|
|
40318
|
-
* roster the runner sees exactly matches the balancer's candidate set. Sorted
|
|
40319
|
-
* by key for determinism. Populated onto `RunnerCameraConfig.inferenceDevices`
|
|
40320
|
-
* ONLY when a `deviceKey` is elected and there are ≥2 entries.
|
|
40321
|
-
*/
|
|
40322
|
-
function buildInferenceDeviceRoster(eligible, caps) {
|
|
40323
|
-
return Object.entries(eligible).map(([deviceKey, weight]) => ({
|
|
40324
|
-
deviceKey,
|
|
40325
|
-
weight: weight > 0 ? weight : 1,
|
|
40326
|
-
maxSessions: caps[deviceKey] ?? null
|
|
40327
|
-
})).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
|
|
40328
|
-
}
|
|
40329
|
-
//#endregion
|
|
40330
|
-
//#region src/node-inference-usability-mirror.ts
|
|
40331
|
-
var NodeInferenceUsabilityMirror = class {
|
|
40332
|
-
state = /* @__PURE__ */ new Map();
|
|
40333
|
-
/**
|
|
40334
|
-
* Fold one observation in and report whether the caller should act.
|
|
40335
|
-
* Never throws.
|
|
40336
|
-
*/
|
|
40337
|
-
observe(nodeId, usable) {
|
|
40338
|
-
const prev = this.state.get(nodeId);
|
|
40339
|
-
if (usable) {
|
|
40340
|
-
this.state.set(nodeId, {
|
|
40341
|
-
usable: true,
|
|
40342
|
-
armed: false
|
|
40343
|
-
});
|
|
40344
|
-
return prev !== void 0 && !prev.usable ? "recovered" : null;
|
|
40345
|
-
}
|
|
40346
|
-
if (prev === void 0) {
|
|
40347
|
-
this.state.set(nodeId, {
|
|
40348
|
-
usable: true,
|
|
40349
|
-
armed: true
|
|
40350
|
-
});
|
|
40351
|
-
return null;
|
|
40352
|
-
}
|
|
40353
|
-
if (!prev.usable) {
|
|
40354
|
-
this.state.set(nodeId, {
|
|
40355
|
-
usable: false,
|
|
40356
|
-
armed: true
|
|
40357
|
-
});
|
|
40358
|
-
return null;
|
|
40359
|
-
}
|
|
40360
|
-
if (!prev.armed) {
|
|
40361
|
-
this.state.set(nodeId, {
|
|
40362
|
-
usable: true,
|
|
40363
|
-
armed: true
|
|
40364
|
-
});
|
|
40365
|
-
return null;
|
|
40366
|
-
}
|
|
40367
|
-
this.state.set(nodeId, {
|
|
40368
|
-
usable: false,
|
|
40369
|
-
armed: true
|
|
40370
|
-
});
|
|
40371
|
-
return "became-unusable";
|
|
40372
|
-
}
|
|
40373
|
-
/** Can this node be given cameras? Unknown nodes answer YES. */
|
|
40374
|
-
isUsable(nodeId) {
|
|
40375
|
-
return this.state.get(nodeId)?.usable ?? true;
|
|
40376
|
-
}
|
|
40377
|
-
/** Nodes currently excluded — for the placement log and diagnostics. */
|
|
40378
|
-
unusableNodeIds() {
|
|
40379
|
-
const out = [];
|
|
40380
|
-
for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
|
|
40381
|
-
return out.toSorted();
|
|
40382
|
-
}
|
|
40383
|
-
forget(nodeId) {
|
|
40384
|
-
this.state.delete(nodeId);
|
|
40385
|
-
}
|
|
40386
|
-
reset() {
|
|
40387
|
-
this.state.clear();
|
|
40388
|
-
}
|
|
40389
|
-
};
|
|
40390
|
-
//#endregion
|
|
40391
|
-
//#region src/inference-device-usability-mirror.ts
|
|
40392
|
-
/**
|
|
40393
|
-
* InferenceDeviceUsabilityMirror — "can this NODE's THIS DEVICE still infer?",
|
|
40394
|
-
* kept off the placement path.
|
|
40395
|
-
*
|
|
40396
|
-
* The per-`(nodeId, deviceKey)` twin of {@link NodeInferenceUsabilityMirror},
|
|
40397
|
-
* and deliberately not a second mechanism: it composes the key and delegates
|
|
40398
|
-
* every decision to that class, so the arm/apply reluctance D49 pinned lives in
|
|
40399
|
-
* exactly one implementation and cannot drift between the node tier and the
|
|
40400
|
-
* device tier.
|
|
40401
|
-
*
|
|
40402
|
-
* ## Why this tier had to exist
|
|
40403
|
-
*
|
|
40404
|
-
* The node tier already answers "does this node have ANY usable accelerator".
|
|
40405
|
-
* On 2026-08-25 the hub's answer was, correctly, yes — its NPU was healthy the
|
|
40406
|
-
* whole time. What died was one DEVICE, `openvino:gpu`, and nothing anywhere
|
|
40407
|
-
* asked that question: the per-dispatch capability gate is keyed on model
|
|
40408
|
-
* FORMAT and `openvino:gpu` and `openvino:npu` share the format `openvino`, so
|
|
40409
|
-
* it is blind between them by construction. The balancer kept rotating cameras
|
|
40410
|
-
* onto the dead pool — 20 sessions in 20 minutes, every one `tieBreak:
|
|
40411
|
-
* "rotation"` — for 31 hours.
|
|
40412
|
-
*
|
|
40413
|
-
* ## Why a mirror and not the event
|
|
40414
|
-
*
|
|
40415
|
-
* D49, verbatim: *a one-shot event never gates on a fallible read*. The gate is
|
|
40416
|
-
* this in-memory mirror, refreshed off the event path by the same
|
|
40417
|
-
* `resolveEligibleInferenceDevices` the dispatcher already runs (plus the
|
|
40418
|
-
* session controller's background refresher). The consequences that buys:
|
|
40419
|
-
*
|
|
40420
|
-
* - **A read that fails changes nothing.** The caller folds in an observation
|
|
40421
|
-
* only when it HAS one; an unreachable node, a version-skewed executor or a
|
|
40422
|
-
* rejected RPC never reaches {@link observe}, so the previous verdict
|
|
40423
|
-
* stands. This is the whole reason the health read is specified as
|
|
40424
|
-
* "synchronous over in-memory state, never throws for its own reasons": an
|
|
40425
|
-
* empty answer must mean *nothing is refused*, not *I could not tell*.
|
|
40426
|
-
* - **Excluding a healthy device needs a second, consecutive read.** Exclusion
|
|
40427
|
-
* is the direction that DESTROYS work — it strands an accelerator that may
|
|
40428
|
-
* be perfectly fine — so one bad observation only ARMS.
|
|
40429
|
-
* - **Re-admitting is immediate and unconditional.** One good observation puts
|
|
40430
|
-
* the device straight back. Being slow to exclude costs some wasted
|
|
40431
|
-
* inference attempts; being slow to re-admit costs an idle accelerator and a
|
|
40432
|
-
* node that looks broken.
|
|
40433
|
-
*/
|
|
40434
|
-
/**
|
|
40435
|
-
* NUL — impossible in both a Moleculer node id and a `<backend>:<device>` key,
|
|
40436
|
-
* so the composite key can never be ambiguous. A separator that CAN occur in
|
|
40437
|
-
* either half makes two distinct pairs collide, and a collision here silently
|
|
40438
|
-
* excludes an accelerator nobody reported.
|
|
40439
|
-
*/
|
|
40440
|
-
var SEPARATOR = "\0";
|
|
40441
|
-
var InferenceDeviceUsabilityMirror = class {
|
|
40442
|
-
/** The one implementation of the arm/apply state machine (D49). */
|
|
40443
|
-
mirror = new NodeInferenceUsabilityMirror();
|
|
40444
|
-
/**
|
|
40445
|
-
* Fold one observation in and report whether the caller should act.
|
|
40446
|
-
* Never throws.
|
|
40447
|
-
*/
|
|
40448
|
-
observe(nodeId, deviceKey, usable) {
|
|
40449
|
-
return this.mirror.observe(`${nodeId}${SEPARATOR}${deviceKey}`, usable);
|
|
40450
|
-
}
|
|
40451
|
-
/** Can the balancer put a session on this device? Unknown pairs answer YES. */
|
|
40452
|
-
isUsable(nodeId, deviceKey) {
|
|
40453
|
-
return this.mirror.isUsable(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
40454
|
-
}
|
|
40455
|
-
/** Pairs currently excluded — for the placement log and diagnostics. */
|
|
40456
|
-
unusableDevices() {
|
|
40457
|
-
return this.mirror.unusableNodeIds().map((composite) => {
|
|
40458
|
-
const at = composite.indexOf(SEPARATOR);
|
|
40459
|
-
return {
|
|
40460
|
-
nodeId: composite.slice(0, at),
|
|
40461
|
-
deviceKey: composite.slice(at + 1)
|
|
40462
|
-
};
|
|
40463
|
-
});
|
|
40464
|
-
}
|
|
40465
|
-
/** The excluded device keys on ONE node. */
|
|
40466
|
-
unusableDeviceKeys(nodeId) {
|
|
40467
|
-
return this.unusableDevices().filter((d) => d.nodeId === nodeId).map((d) => d.deviceKey);
|
|
40468
|
-
}
|
|
40469
|
-
forget(nodeId, deviceKey) {
|
|
40470
|
-
this.mirror.forget(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
40471
|
-
}
|
|
40472
|
-
reset() {
|
|
40473
|
-
this.mirror.reset();
|
|
40474
|
-
}
|
|
40475
|
-
};
|
|
40476
|
-
/**
|
|
40477
|
-
* Fold ONE node's health answer into the mirror and return what changed.
|
|
40478
|
-
*
|
|
40479
|
-
* This is the whole reading discipline, in one place, because both halves of it
|
|
40480
|
-
* are easy to get subtly wrong and neither failure is visible in a log:
|
|
40481
|
-
*
|
|
40482
|
-
* - **`unhealthy === null` — the read FAILED — changes nothing.** Not a single
|
|
40483
|
-
* entry is touched. An unreachable node, a version-skewed executor or a
|
|
40484
|
-
* rejected RPC must be distinguishable from "asked, nothing is refused", or
|
|
40485
|
-
* a flaky link silently re-admits a dead accelerator (D49).
|
|
40486
|
-
* - **Every device the node HAS is observed**, not merely the refused ones.
|
|
40487
|
-
* The first draft observed `refused ∪ already-excluded`, which omits exactly
|
|
40488
|
-
* the devices the mirror has ARMED — so their disarming good read never
|
|
40489
|
-
* arrived and two bad reads an HOUR apart, with a hundred healthy ones
|
|
40490
|
-
* between them, excluded a working accelerator. "Consecutive" is only a
|
|
40491
|
-
* property if the good observations are delivered.
|
|
40492
|
-
*
|
|
40493
|
-
* Pure with respect to everything except `mirror`, and never throws — it is
|
|
40494
|
-
* called from the dispatcher's own read path.
|
|
40495
|
-
*/
|
|
40496
|
-
function foldInferenceDeviceHealth(mirror, nodeId, unhealthy, present) {
|
|
40497
|
-
if (unhealthy === null) return [];
|
|
40498
|
-
const refused = new Set(unhealthy);
|
|
40499
|
-
const observed = new Set([
|
|
40500
|
-
...present,
|
|
40501
|
-
...refused,
|
|
40502
|
-
...mirror.unusableDeviceKeys(nodeId)
|
|
40503
|
-
]);
|
|
40504
|
-
const changes = [];
|
|
40505
|
-
for (const deviceKey of observed) {
|
|
40506
|
-
const transition = mirror.observe(nodeId, deviceKey, !refused.has(deviceKey));
|
|
40507
|
-
if (transition !== null) changes.push({
|
|
40508
|
-
deviceKey,
|
|
40509
|
-
transition
|
|
40510
|
-
});
|
|
40511
|
-
}
|
|
40512
|
-
return changes;
|
|
40513
|
-
}
|
|
40514
|
-
//#endregion
|
|
40515
|
-
//#region src/orchestrator-types.ts
|
|
40516
|
-
var PHASE_MODE_VALUES = new Set([
|
|
40517
|
-
"disabled",
|
|
40518
|
-
"always-on",
|
|
40519
|
-
"on-motion"
|
|
40520
|
-
]);
|
|
40521
|
-
function isPipelinePhaseMode(v) {
|
|
40522
|
-
return PHASE_MODE_VALUES.has(v);
|
|
40523
|
-
}
|
|
40524
|
-
/**
|
|
40525
|
-
* Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
|
|
40526
|
-
* that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
|
|
40527
|
-
* `reconcileDispatch` is additive-only and never revisits these, so a slow
|
|
40528
|
-
* safety-net timer + event-driven debounce triggers recover them.
|
|
40380
|
+
* Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
|
|
40381
|
+
* that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
|
|
40382
|
+
* `reconcileDispatch` is additive-only and never revisits these, so a slow
|
|
40383
|
+
* safety-net timer + event-driven debounce triggers recover them.
|
|
40529
40384
|
*/
|
|
40530
40385
|
var PENDING_RETRY_INTERVAL_MS = 6e4;
|
|
40531
40386
|
/** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
|
|
@@ -40644,264 +40499,6 @@ var pipelineOrchestratorActions = defineCustomActions({
|
|
|
40644
40499
|
*/
|
|
40645
40500
|
var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
|
|
40646
40501
|
//#endregion
|
|
40647
|
-
//#region src/audio-chunk-poller.ts
|
|
40648
|
-
/**
|
|
40649
|
-
* `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
|
|
40650
|
-
* plane (Phase 5 / D9).
|
|
40651
|
-
*
|
|
40652
|
-
* Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
|
|
40653
|
-
* path. A live callback cannot cross a process boundary; once the `pipeline`
|
|
40654
|
-
* group is dissolved (Task 8) the orchestrator runs in a different process
|
|
40655
|
-
* from the broker, so audio delivery must go over tRPC.
|
|
40656
|
-
*
|
|
40657
|
-
* The consumer:
|
|
40658
|
-
*
|
|
40659
|
-
* 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
|
|
40660
|
-
* registers a per-subscription bounded FIFO queue and returns a
|
|
40661
|
-
* `subscriptionId`;
|
|
40662
|
-
* 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
|
|
40663
|
-
* interval, draining `DecodedAudioChunk`s in FIFO arrival order;
|
|
40664
|
-
* 3. feeds each chunk to its downstream audio logic;
|
|
40665
|
-
* 4. on teardown, `unsubscribeAudioChunks`.
|
|
40666
|
-
*
|
|
40667
|
-
* Audio is not latency-critical like video, and chunks arrive only ~every
|
|
40668
|
-
* 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
|
|
40669
|
-
* a small per-poll burst keeps latency low without busy-spinning. The
|
|
40670
|
-
* broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
|
|
40671
|
-
* loses a chunk.
|
|
40672
|
-
*
|
|
40673
|
-
* Boot-race tolerance: the broker for a given camStream may not be registered
|
|
40674
|
-
* yet when the orchestrator wires the subscription (provider addons publish
|
|
40675
|
-
* their cameraStreams asynchronously after their probe completes).
|
|
40676
|
-
* `subscribeAudioChunks` retries with exponential backoff (capped at
|
|
40677
|
-
* `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
|
|
40678
|
-
* teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
|
|
40679
|
-
* shape so video and audio plumbing self-heal identically.
|
|
40680
|
-
*/
|
|
40681
|
-
/** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
|
|
40682
|
-
var POLL_INTERVAL_MS$1 = 200;
|
|
40683
|
-
/** How many chunks to drain per poll — a small burst absorbs jitter. */
|
|
40684
|
-
var PULL_MAX_COUNT = 8;
|
|
40685
|
-
/**
|
|
40686
|
-
* Consecutive pull failures before we attempt to re-subscribe. A single failed
|
|
40687
|
-
* poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
|
|
40688
|
-
* sustained failure means the broker child restarted and dropped our
|
|
40689
|
-
* subscription, so we re-establish it.
|
|
40690
|
-
*/
|
|
40691
|
-
var RESUBSCRIBE_AFTER_FAILURES = 2;
|
|
40692
|
-
/** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
|
|
40693
|
-
var RESUBSCRIBE_THROTTLE_TICKS = 5;
|
|
40694
|
-
/** First subscribe-retry delay, doubled on every subsequent failure. */
|
|
40695
|
-
var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
|
|
40696
|
-
/**
|
|
40697
|
-
* Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
|
|
40698
|
-
* enough to recover within a single reconcile of the orchestrator and slow
|
|
40699
|
-
* enough that a misconfigured camStream costs ~12 op/min, not 5/s.
|
|
40700
|
-
*/
|
|
40701
|
-
var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
|
|
40702
|
-
/**
|
|
40703
|
-
* Attempts after which a still-failing subscribe escalates from the fast 5 s
|
|
40704
|
-
* ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
|
|
40705
|
-
* minute of fast retries — plenty for the boot races the 5 s ceiling exists
|
|
40706
|
-
* for. A broker that is STILL absent after that is a long-lived condition
|
|
40707
|
-
* (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
|
|
40708
|
-
* to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
|
|
40709
|
-
* churn. The slow loop stays alive so audio still recovers automatically
|
|
40710
|
-
* (≤60 s) once the camera is re-enabled.
|
|
40711
|
-
*/
|
|
40712
|
-
var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
|
|
40713
|
-
var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
|
|
40714
|
-
/**
|
|
40715
|
-
* Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
|
|
40716
|
-
*
|
|
40717
|
-
* Always resolves to a teardown closure — when the broker is not yet
|
|
40718
|
-
* registered the closure cancels the ongoing retry loop; when polling is
|
|
40719
|
-
* active it stops the loop and releases the broker subscription. Mirrors
|
|
40720
|
-
* `startFrameHandlePoller` so video and audio recover identically.
|
|
40721
|
-
*/
|
|
40722
|
-
function startAudioChunkPoller(options) {
|
|
40723
|
-
const lifecycle = {
|
|
40724
|
-
stopped: false,
|
|
40725
|
-
retryTimer: void 0,
|
|
40726
|
-
pollTimer: void 0,
|
|
40727
|
-
activeSubscriptionId: null
|
|
40728
|
-
};
|
|
40729
|
-
const teardown = () => {
|
|
40730
|
-
if (lifecycle.stopped) return;
|
|
40731
|
-
lifecycle.stopped = true;
|
|
40732
|
-
if (lifecycle.retryTimer) {
|
|
40733
|
-
clearTimeout(lifecycle.retryTimer);
|
|
40734
|
-
lifecycle.retryTimer = void 0;
|
|
40735
|
-
}
|
|
40736
|
-
if (lifecycle.pollTimer) {
|
|
40737
|
-
clearTimeout(lifecycle.pollTimer);
|
|
40738
|
-
lifecycle.pollTimer = void 0;
|
|
40739
|
-
}
|
|
40740
|
-
const subId = lifecycle.activeSubscriptionId;
|
|
40741
|
-
if (subId) {
|
|
40742
|
-
lifecycle.activeSubscriptionId = null;
|
|
40743
|
-
options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
|
|
40744
|
-
options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
|
|
40745
|
-
brokerId: options.brokerId,
|
|
40746
|
-
subscriptionId: subId,
|
|
40747
|
-
error: errMsg(err)
|
|
40748
|
-
} });
|
|
40749
|
-
});
|
|
40750
|
-
}
|
|
40751
|
-
};
|
|
40752
|
-
subscribeWithRetry(options, lifecycle);
|
|
40753
|
-
return teardown;
|
|
40754
|
-
}
|
|
40755
|
-
/**
|
|
40756
|
-
* Run the subscribe → poll handshake with exponential backoff on subscribe
|
|
40757
|
-
* failures. Resolves once the subscription is acquired (and the poll loop has
|
|
40758
|
-
* been started) or once `lifecycle.stopped` flips, whichever comes first.
|
|
40759
|
-
*/
|
|
40760
|
-
async function subscribeWithRetry(options, lifecycle) {
|
|
40761
|
-
const { api, brokerId, tag, ownerNodeId, logger } = options;
|
|
40762
|
-
const pin = nodePin(ownerNodeId);
|
|
40763
|
-
let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
|
|
40764
|
-
let attempt = 0;
|
|
40765
|
-
while (!lifecycle.stopped) {
|
|
40766
|
-
attempt += 1;
|
|
40767
|
-
try {
|
|
40768
|
-
const result = await api.streamBroker.subscribeAudioChunks.mutate({
|
|
40769
|
-
brokerId,
|
|
40770
|
-
tag
|
|
40771
|
-
}, pin);
|
|
40772
|
-
if (lifecycle.stopped) {
|
|
40773
|
-
await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
|
|
40774
|
-
logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
|
|
40775
|
-
brokerId,
|
|
40776
|
-
subscriptionId: result.subscriptionId,
|
|
40777
|
-
error: errMsg(err)
|
|
40778
|
-
} });
|
|
40779
|
-
});
|
|
40780
|
-
return;
|
|
40781
|
-
}
|
|
40782
|
-
if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
|
|
40783
|
-
brokerId,
|
|
40784
|
-
tag,
|
|
40785
|
-
attempt
|
|
40786
|
-
} });
|
|
40787
|
-
lifecycle.activeSubscriptionId = result.subscriptionId;
|
|
40788
|
-
startPolling(options, lifecycle);
|
|
40789
|
-
return;
|
|
40790
|
-
} catch (err) {
|
|
40791
|
-
if (lifecycle.stopped) return;
|
|
40792
|
-
if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
|
|
40793
|
-
brokerId,
|
|
40794
|
-
tag,
|
|
40795
|
-
error: errMsg(err),
|
|
40796
|
-
nextRetryInMs: backoffMs
|
|
40797
|
-
} });
|
|
40798
|
-
else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
|
|
40799
|
-
brokerId,
|
|
40800
|
-
tag,
|
|
40801
|
-
attempt,
|
|
40802
|
-
error: errMsg(err),
|
|
40803
|
-
nextRetryInMs: backoffMs
|
|
40804
|
-
} });
|
|
40805
|
-
await sleep(backoffMs, lifecycle);
|
|
40806
|
-
backoffMs = Math.min(attempt >= PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS ? LONG_SUBSCRIBE_RETRY_BACKOFF_MS : MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
|
|
40807
|
-
}
|
|
40808
|
-
}
|
|
40809
|
-
}
|
|
40810
|
-
/**
|
|
40811
|
-
* Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
|
|
40812
|
-
* trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
|
|
40813
|
-
* the broker child restart case where our `subscriptionId` is silently
|
|
40814
|
-
* disowned.
|
|
40815
|
-
*/
|
|
40816
|
-
function startPolling(options, lifecycle) {
|
|
40817
|
-
const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
|
|
40818
|
-
const pin = nodePin(ownerNodeId);
|
|
40819
|
-
let consecutiveFailures = 0;
|
|
40820
|
-
const resubscribe = async () => {
|
|
40821
|
-
try {
|
|
40822
|
-
const result = await api.streamBroker.subscribeAudioChunks.mutate({
|
|
40823
|
-
brokerId,
|
|
40824
|
-
tag
|
|
40825
|
-
}, pin);
|
|
40826
|
-
lifecycle.activeSubscriptionId = result.subscriptionId;
|
|
40827
|
-
logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
|
|
40828
|
-
brokerId,
|
|
40829
|
-
tag,
|
|
40830
|
-
subscriptionId: result.subscriptionId,
|
|
40831
|
-
afterFailures: consecutiveFailures
|
|
40832
|
-
} });
|
|
40833
|
-
return true;
|
|
40834
|
-
} catch {
|
|
40835
|
-
return false;
|
|
40836
|
-
}
|
|
40837
|
-
};
|
|
40838
|
-
const tick = async () => {
|
|
40839
|
-
if (lifecycle.stopped) return;
|
|
40840
|
-
const subId = lifecycle.activeSubscriptionId;
|
|
40841
|
-
if (!subId) return;
|
|
40842
|
-
try {
|
|
40843
|
-
const chunks = await api.streamBroker.pullAudioChunks.query({
|
|
40844
|
-
subscriptionId: subId,
|
|
40845
|
-
maxCount: PULL_MAX_COUNT
|
|
40846
|
-
}, pin);
|
|
40847
|
-
if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
|
|
40848
|
-
brokerId,
|
|
40849
|
-
subscriptionId: subId
|
|
40850
|
-
} });
|
|
40851
|
-
consecutiveFailures = 0;
|
|
40852
|
-
for (const chunk of chunks) {
|
|
40853
|
-
if (lifecycle.stopped) break;
|
|
40854
|
-
await onChunk(chunk);
|
|
40855
|
-
}
|
|
40856
|
-
} catch (err) {
|
|
40857
|
-
consecutiveFailures += 1;
|
|
40858
|
-
if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
|
|
40859
|
-
brokerId,
|
|
40860
|
-
subscriptionId: subId,
|
|
40861
|
-
error: errMsg(err)
|
|
40862
|
-
} });
|
|
40863
|
-
if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
|
|
40864
|
-
}
|
|
40865
|
-
if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS$1);
|
|
40866
|
-
};
|
|
40867
|
-
tick();
|
|
40868
|
-
}
|
|
40869
|
-
/**
|
|
40870
|
-
* Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
|
|
40871
|
-
* keep a local wrapper around the shared {@link sleep} helper because
|
|
40872
|
-
* the lifecycle tracks the active retry timer for `teardown()` to
|
|
40873
|
-
* clear; pure `sleep()` would leak the timer if teardown fired while
|
|
40874
|
-
* we were waiting.
|
|
40875
|
-
*/
|
|
40876
|
-
function sleep(ms, lifecycle) {
|
|
40877
|
-
return new Promise((resolve) => {
|
|
40878
|
-
if (lifecycle.stopped) {
|
|
40879
|
-
resolve();
|
|
40880
|
-
return;
|
|
40881
|
-
}
|
|
40882
|
-
lifecycle.retryTimer = setTimeout(() => {
|
|
40883
|
-
lifecycle.retryTimer = void 0;
|
|
40884
|
-
resolve();
|
|
40885
|
-
}, ms);
|
|
40886
|
-
});
|
|
40887
|
-
}
|
|
40888
|
-
//#endregion
|
|
40889
|
-
//#region src/audio-load-balancer.ts
|
|
40890
|
-
function balanceAudio(input) {
|
|
40891
|
-
if (input.nodes.length === 0) return null;
|
|
40892
|
-
if (input.preferredNode) {
|
|
40893
|
-
const pinned = input.nodes.find((n) => n.nodeId === input.preferredNode);
|
|
40894
|
-
if (pinned) return {
|
|
40895
|
-
nodeId: pinned.nodeId,
|
|
40896
|
-
reason: "manual"
|
|
40897
|
-
};
|
|
40898
|
-
}
|
|
40899
|
-
return {
|
|
40900
|
-
nodeId: input.nodes.slice().toSorted((a, b) => a.deviceCount - b.deviceCount)[0].nodeId,
|
|
40901
|
-
reason: "capacity"
|
|
40902
|
-
};
|
|
40903
|
-
}
|
|
40904
|
-
//#endregion
|
|
40905
40502
|
//#region src/audio-window-accumulator.ts
|
|
40906
40503
|
var AudioWindowAccumulator = class {
|
|
40907
40504
|
deviceId;
|
|
@@ -41405,234 +41002,847 @@ var AudioSubscriptionController = class {
|
|
|
41405
41002
|
meta: { error: errMsg(err) }
|
|
41406
41003
|
});
|
|
41407
41004
|
});
|
|
41408
|
-
}, windowMs);
|
|
41409
|
-
this.motionAudioWindowTimers.set(deviceId, timer);
|
|
41410
|
-
}
|
|
41411
|
-
/** True while a motion-driven audio window is open for this device. */
|
|
41412
|
-
isMotionAudioWindowOpen(deviceId) {
|
|
41413
|
-
return this.motionAudioWindowTimers.has(deviceId);
|
|
41414
|
-
}
|
|
41415
|
-
/**
|
|
41416
|
-
* Close an on-motion audio window and drop its subscription. Shared by both
|
|
41417
|
-
* closers (quiet window elapsed / falling edge cooldown) so they can never
|
|
41418
|
-
* disagree about what "closed" means. `reason` is logged so a camera that
|
|
41419
|
-
* loses audio can always be told WHY from the per-device log view.
|
|
41420
|
-
*/
|
|
41421
|
-
async closeMotionAudioWindow(deviceId, reason) {
|
|
41422
|
-
const pendingWindow = this.motionAudioWindowTimers.get(deviceId);
|
|
41423
|
-
if (pendingWindow) {
|
|
41424
|
-
clearTimeout(pendingWindow);
|
|
41425
|
-
this.motionAudioWindowTimers.delete(deviceId);
|
|
41005
|
+
}, windowMs);
|
|
41006
|
+
this.motionAudioWindowTimers.set(deviceId, timer);
|
|
41007
|
+
}
|
|
41008
|
+
/** True while a motion-driven audio window is open for this device. */
|
|
41009
|
+
isMotionAudioWindowOpen(deviceId) {
|
|
41010
|
+
return this.motionAudioWindowTimers.has(deviceId);
|
|
41011
|
+
}
|
|
41012
|
+
/**
|
|
41013
|
+
* Close an on-motion audio window and drop its subscription. Shared by both
|
|
41014
|
+
* closers (quiet window elapsed / falling edge cooldown) so they can never
|
|
41015
|
+
* disagree about what "closed" means. `reason` is logged so a camera that
|
|
41016
|
+
* loses audio can always be told WHY from the per-device log view.
|
|
41017
|
+
*/
|
|
41018
|
+
async closeMotionAudioWindow(deviceId, reason) {
|
|
41019
|
+
const pendingWindow = this.motionAudioWindowTimers.get(deviceId);
|
|
41020
|
+
if (pendingWindow) {
|
|
41021
|
+
clearTimeout(pendingWindow);
|
|
41022
|
+
this.motionAudioWindowTimers.delete(deviceId);
|
|
41023
|
+
}
|
|
41024
|
+
await this.withAudioSubLock(deviceId, async () => {
|
|
41025
|
+
const unsub = this.audioSubscriptions.get(deviceId);
|
|
41026
|
+
if (!unsub) return;
|
|
41027
|
+
try {
|
|
41028
|
+
unsub();
|
|
41029
|
+
} catch {}
|
|
41030
|
+
this.audioSubscriptions.delete(deviceId);
|
|
41031
|
+
this.deps.logger.info("lazy audio: window closed", {
|
|
41032
|
+
tags: { deviceId },
|
|
41033
|
+
meta: { reason }
|
|
41034
|
+
});
|
|
41035
|
+
});
|
|
41036
|
+
}
|
|
41037
|
+
/**
|
|
41038
|
+
* Audio teardown for one device — the audio half of `stopDetection`.
|
|
41039
|
+
* Tears down through the per-device lock so it can't race a concurrent
|
|
41040
|
+
* subscribe (which would re-store a handle this teardown never sees).
|
|
41041
|
+
*/
|
|
41042
|
+
async stopForDevice(deviceId) {
|
|
41043
|
+
await this.withAudioSubLock(deviceId, async () => {
|
|
41044
|
+
const unsub = this.audioSubscriptions.get(deviceId);
|
|
41045
|
+
if (unsub) {
|
|
41046
|
+
try {
|
|
41047
|
+
unsub();
|
|
41048
|
+
} catch {}
|
|
41049
|
+
this.audioSubscriptions.delete(deviceId);
|
|
41050
|
+
}
|
|
41051
|
+
});
|
|
41052
|
+
const lazyTimer = this.lazyAudioTeardownTimers.get(deviceId);
|
|
41053
|
+
if (lazyTimer) {
|
|
41054
|
+
clearTimeout(lazyTimer);
|
|
41055
|
+
this.lazyAudioTeardownTimers.delete(deviceId);
|
|
41056
|
+
}
|
|
41057
|
+
const windowTimer = this.motionAudioWindowTimers.get(deviceId);
|
|
41058
|
+
if (windowTimer) {
|
|
41059
|
+
clearTimeout(windowTimer);
|
|
41060
|
+
this.motionAudioWindowTimers.delete(deviceId);
|
|
41061
|
+
}
|
|
41062
|
+
this.audioAssignments.delete(deviceId);
|
|
41063
|
+
}
|
|
41064
|
+
/**
|
|
41065
|
+
* Centralized write into `audioSubscriptions`. If shutdown has begun, the
|
|
41066
|
+
* map has already been (or is about to be) cleared lock-free in
|
|
41067
|
+
* `shutdown()`; storing here would leak a zombie entry whose `unsub` is
|
|
41068
|
+
* never called. So when shutting down we immediately invoke `unsub`
|
|
41069
|
+
* (best-effort, error-swallowed) and DO NOT store. `protected` so
|
|
41070
|
+
* `audio-sub-lock.spec.ts`'s test subclass can assert the shutdown-guard
|
|
41071
|
+
* behavior without casts.
|
|
41072
|
+
*/
|
|
41073
|
+
storeAudioSub(deviceId, unsub) {
|
|
41074
|
+
if (this.audioShuttingDown) {
|
|
41075
|
+
try {
|
|
41076
|
+
unsub();
|
|
41077
|
+
} catch {}
|
|
41078
|
+
return;
|
|
41079
|
+
}
|
|
41080
|
+
this.audioSubscriptions.set(deviceId, unsub);
|
|
41081
|
+
}
|
|
41082
|
+
/**
|
|
41083
|
+
* Serialize an audio-subscription critical section per device. `fn` is
|
|
41084
|
+
* chained onto the device's current lock tail, so concurrent calls for the
|
|
41085
|
+
* SAME deviceId run sequentially (FIFO); different deviceIds never block
|
|
41086
|
+
* each other. Thin delegate onto the `audioSubLocks` `KeyedAsyncLock`
|
|
41087
|
+
* instance. `protected` so `audio-sub-lock.spec.ts`'s test subclass can
|
|
41088
|
+
* drive the lock without casts.
|
|
41089
|
+
*/
|
|
41090
|
+
withAudioSubLock(deviceId, fn) {
|
|
41091
|
+
return this.audioSubLocks.run(deviceId, fn);
|
|
41092
|
+
}
|
|
41093
|
+
/**
|
|
41094
|
+
* Subscribe to decoded audio chunks for a camera and feed them into the
|
|
41095
|
+
* audio-analyzer. Reads the analyzer's settings via its own
|
|
41096
|
+
* `resolveDeviceSettings(deviceId)` method so the orchestrator does not
|
|
41097
|
+
* touch the audio-analyzer schema field names directly.
|
|
41098
|
+
*/
|
|
41099
|
+
async subscribeAudioStream(deviceId, config) {
|
|
41100
|
+
const api = this.deps.api();
|
|
41101
|
+
if (!api) {
|
|
41102
|
+
this.deps.logger.warn("this.ctx.api not available — cannot subscribe audio", { tags: { deviceId } });
|
|
41103
|
+
return null;
|
|
41104
|
+
}
|
|
41105
|
+
if (!await this.deps.isAudioAnalysisActive(deviceId)) return null;
|
|
41106
|
+
if (config.audioMode === "disabled") {
|
|
41107
|
+
this.deps.logger.debug("audio subscribe skipped: audioMode=disabled", { tags: { deviceId } });
|
|
41108
|
+
return null;
|
|
41109
|
+
}
|
|
41110
|
+
if (config.audioMode === "on-motion" && !this.isMotionAudioWindowOpen(deviceId)) {
|
|
41111
|
+
this.deps.logger.info("audio subscribe deferred: audioMode=on-motion, no window open", { tags: { deviceId } });
|
|
41112
|
+
return null;
|
|
41113
|
+
}
|
|
41114
|
+
const audioStream = config.audioStreamId ?? config.motionStreamId;
|
|
41115
|
+
const audioBrokerId = makeSourceBrokerId(deviceId, audioStream);
|
|
41116
|
+
if ((await this.deps.probeAudioTrack(deviceId, audioStream)).kind === "absent") {
|
|
41117
|
+
this.deps.logger.warn("audio subscription REFUSED — this stream carries no audio track", {
|
|
41118
|
+
tags: { deviceId },
|
|
41119
|
+
meta: {
|
|
41120
|
+
camStreamId: audioStream,
|
|
41121
|
+
brokerId: audioBrokerId,
|
|
41122
|
+
selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
|
|
41123
|
+
hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
|
|
41124
|
+
}
|
|
41125
|
+
});
|
|
41126
|
+
return null;
|
|
41127
|
+
}
|
|
41128
|
+
const settings = await api.audioAnalysis.resolveDeviceSettings.query({ deviceId });
|
|
41129
|
+
if (!settings) {
|
|
41130
|
+
this.deps.logger.warn("audio-analysis returned no settings — audio subscription skipped", { tags: { deviceId } });
|
|
41131
|
+
return null;
|
|
41132
|
+
}
|
|
41133
|
+
const audioNodeId = await this.dispatch(deviceId);
|
|
41134
|
+
const isRemoteAudio = audioNodeId !== this.deps.localNodeId();
|
|
41135
|
+
this.deps.logger.info("audio subscription: resolved audio node", {
|
|
41136
|
+
tags: { deviceId },
|
|
41137
|
+
meta: {
|
|
41138
|
+
audioNodeId,
|
|
41139
|
+
isRemote: isRemoteAudio
|
|
41140
|
+
}
|
|
41141
|
+
});
|
|
41142
|
+
const accumulator = new AudioWindowAccumulator(deviceId);
|
|
41143
|
+
const teardown = startAudioChunkPoller({
|
|
41144
|
+
api,
|
|
41145
|
+
brokerId: audioBrokerId,
|
|
41146
|
+
tag: "audio-analyzer",
|
|
41147
|
+
ownerNodeId: this.deps.ingestNode(),
|
|
41148
|
+
logger: this.deps.logger.withTags({ deviceId }),
|
|
41149
|
+
onChunk: async (chunk) => {
|
|
41150
|
+
this.deps.watchdogNote(deviceId, "audio");
|
|
41151
|
+
try {
|
|
41152
|
+
const audioChunkInput = accumulator.push(chunk);
|
|
41153
|
+
if (!audioChunkInput) return;
|
|
41154
|
+
const result = await api.audioAnalyzer.analyseChunk.mutate({
|
|
41155
|
+
chunk: audioChunkInput,
|
|
41156
|
+
settings,
|
|
41157
|
+
...isRemoteAudio ? { nodeId: audioNodeId } : {}
|
|
41158
|
+
});
|
|
41159
|
+
if (!result) return;
|
|
41160
|
+
const frame = buildAudioResultFrame(deviceId, result);
|
|
41161
|
+
this.deps.eventBus.emit({
|
|
41162
|
+
id: `audio-inference-${deviceId}-${Date.now()}`,
|
|
41163
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
41164
|
+
source: {
|
|
41165
|
+
type: "device",
|
|
41166
|
+
id: deviceId,
|
|
41167
|
+
nodeId: "hub",
|
|
41168
|
+
addonId: "pipeline-orchestrator",
|
|
41169
|
+
deviceId
|
|
41170
|
+
},
|
|
41171
|
+
category: EventCategory.PipelineAudioInferenceResult,
|
|
41172
|
+
data: {
|
|
41173
|
+
deviceId,
|
|
41174
|
+
frame,
|
|
41175
|
+
nodeId: "hub"
|
|
41176
|
+
}
|
|
41177
|
+
});
|
|
41178
|
+
} catch (err) {
|
|
41179
|
+
const msg = errMsg(err);
|
|
41180
|
+
this.deps.logger.error("Audio analysis failed", {
|
|
41181
|
+
tags: { deviceId },
|
|
41182
|
+
meta: { error: msg }
|
|
41183
|
+
});
|
|
41184
|
+
}
|
|
41185
|
+
}
|
|
41186
|
+
});
|
|
41187
|
+
this.deps.logger.info("Audio stream subscribed", { tags: { deviceId } });
|
|
41188
|
+
return () => {
|
|
41189
|
+
teardown();
|
|
41190
|
+
accumulator.reset();
|
|
41191
|
+
};
|
|
41192
|
+
}
|
|
41193
|
+
/**
|
|
41194
|
+
* Set true at the very start of `onShutdown`, before the audio teardown /
|
|
41195
|
+
* map clears below. Once set, `withAudioSubLock` turns queued/new critical
|
|
41196
|
+
* sections into no-ops and `storeAudioSub` refuses to store, so no
|
|
41197
|
+
* critical section that was in-flight (or queued) when shutdown began can
|
|
41198
|
+
* resurrect a zombie subscription into the cleared `audioSubscriptions`
|
|
41199
|
+
* map. MUST be called before anything else in `onShutdown` that could
|
|
41200
|
+
* race a queued audio critical section (mirrors the original
|
|
41201
|
+
* `this.audioShuttingDown = true` being the very first statement).
|
|
41202
|
+
*/
|
|
41203
|
+
beginShutdown() {
|
|
41204
|
+
this.audioShuttingDown = true;
|
|
41205
|
+
}
|
|
41206
|
+
/**
|
|
41207
|
+
* Full audio teardown — combines the former `onShutdown`'s two separate
|
|
41208
|
+
* audio blocks (lazy-teardown-timer clear, then — after several unrelated
|
|
41209
|
+
* session/reconcile/load-shed clears — subscription teardown + lock clear
|
|
41210
|
+
* + assignment-map clears) into one call. Safe to combine: both blocks
|
|
41211
|
+
* are synchronous with no interleaved `await`, and every original
|
|
41212
|
+
* statement between them (`sessionRegistry.clear()`,
|
|
41213
|
+
* `cameraFpsMap.clear()`, `remoteHealthAttempts.clear()`,
|
|
41214
|
+
* `loadShedState.clear()`, `loadShedResumeTimer` cleanup) touches state
|
|
41215
|
+
* fully disjoint from anything audio — so their relative order to each
|
|
41216
|
+
* other is unaffected, and the audio-internal order (timers →
|
|
41217
|
+
* subscriptions → lock → assignment maps) is reproduced exactly.
|
|
41218
|
+
*/
|
|
41219
|
+
shutdown() {
|
|
41220
|
+
for (const t of this.lazyAudioTeardownTimers.values()) clearTimeout(t);
|
|
41221
|
+
this.lazyAudioTeardownTimers.clear();
|
|
41222
|
+
for (const t of this.motionAudioWindowTimers.values()) clearTimeout(t);
|
|
41223
|
+
this.motionAudioWindowTimers.clear();
|
|
41224
|
+
for (const unsub of this.audioSubscriptions.values()) try {
|
|
41225
|
+
unsub();
|
|
41226
|
+
} catch {}
|
|
41227
|
+
this.audioSubscriptions.clear();
|
|
41228
|
+
this.audioSubLocks.clear();
|
|
41229
|
+
this.audioAssignments.clear();
|
|
41230
|
+
this.readyAudioNodes.clear();
|
|
41231
|
+
}
|
|
41232
|
+
};
|
|
41233
|
+
//#endregion
|
|
41234
|
+
//#region src/disk-reconcile-fleet.ts
|
|
41235
|
+
async function reconcileFleetFromDisk(deps) {
|
|
41236
|
+
const deviceIds = await deps.listDeviceIds();
|
|
41237
|
+
const failed = [];
|
|
41238
|
+
let cameras = 0;
|
|
41239
|
+
let mediaDropped = 0;
|
|
41240
|
+
let tracks = 0;
|
|
41241
|
+
let events = 0;
|
|
41242
|
+
let completed = 0;
|
|
41243
|
+
for (const deviceId of deviceIds) {
|
|
41244
|
+
try {
|
|
41245
|
+
await deps.rescanRecordings(deviceId);
|
|
41246
|
+
const counts = await deps.reconcileAnalytics(deviceId);
|
|
41247
|
+
cameras += 1;
|
|
41248
|
+
mediaDropped += counts.mediaDropped;
|
|
41249
|
+
tracks += counts.tracks;
|
|
41250
|
+
events += counts.events;
|
|
41251
|
+
} catch {
|
|
41252
|
+
failed.push(deviceId);
|
|
41253
|
+
}
|
|
41254
|
+
completed += 1;
|
|
41255
|
+
deps.onProgress?.({
|
|
41256
|
+
deviceId,
|
|
41257
|
+
total: deviceIds.length,
|
|
41258
|
+
completed,
|
|
41259
|
+
failed: [...failed],
|
|
41260
|
+
mediaDropped,
|
|
41261
|
+
tracks,
|
|
41262
|
+
events
|
|
41263
|
+
});
|
|
41264
|
+
}
|
|
41265
|
+
return {
|
|
41266
|
+
cameras,
|
|
41267
|
+
failed,
|
|
41268
|
+
mediaDropped,
|
|
41269
|
+
tracks,
|
|
41270
|
+
events
|
|
41271
|
+
};
|
|
41272
|
+
}
|
|
41273
|
+
//#endregion
|
|
41274
|
+
//#region src/disk-reconcile-job.ts
|
|
41275
|
+
/**
|
|
41276
|
+
* In-memory disk-wins fleet job. The tRPC mutation starts this and returns
|
|
41277
|
+
* immediately; the walk runs in the addon process so a 60s UDS timeout cannot
|
|
41278
|
+
* abort it. Status is polled via getReconcileFromDiskStatus.
|
|
41279
|
+
*/
|
|
41280
|
+
function idleDiskReconcileJob() {
|
|
41281
|
+
return {
|
|
41282
|
+
state: "idle",
|
|
41283
|
+
total: 0,
|
|
41284
|
+
completed: 0,
|
|
41285
|
+
currentDeviceId: null,
|
|
41286
|
+
failed: [],
|
|
41287
|
+
mediaDropped: 0,
|
|
41288
|
+
tracks: 0,
|
|
41289
|
+
events: 0,
|
|
41290
|
+
startedAtMs: null,
|
|
41291
|
+
finishedAtMs: null,
|
|
41292
|
+
error: null
|
|
41293
|
+
};
|
|
41294
|
+
}
|
|
41295
|
+
function isTimeoutError(err) {
|
|
41296
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41297
|
+
return /timed out/i.test(message);
|
|
41298
|
+
}
|
|
41299
|
+
async function withTimeoutRetry(run) {
|
|
41300
|
+
try {
|
|
41301
|
+
return await run();
|
|
41302
|
+
} catch (err) {
|
|
41303
|
+
if (!isTimeoutError(err)) throw err;
|
|
41304
|
+
return await run();
|
|
41305
|
+
}
|
|
41306
|
+
}
|
|
41307
|
+
function createDiskReconcileJobRunner(now = Date.now) {
|
|
41308
|
+
let job = idleDiskReconcileJob();
|
|
41309
|
+
let inFlight = null;
|
|
41310
|
+
const snapshot = () => job;
|
|
41311
|
+
const start = (deps) => {
|
|
41312
|
+
if (job.state === "running" && inFlight) return job;
|
|
41313
|
+
job = {
|
|
41314
|
+
...idleDiskReconcileJob(),
|
|
41315
|
+
state: "running",
|
|
41316
|
+
startedAtMs: now()
|
|
41317
|
+
};
|
|
41318
|
+
deps.log?.("pipeline disk reconcile started");
|
|
41319
|
+
inFlight = (async () => {
|
|
41320
|
+
try {
|
|
41321
|
+
const result = await reconcileFleetFromDisk({
|
|
41322
|
+
listDeviceIds: deps.listDeviceIds,
|
|
41323
|
+
rescanRecordings: (deviceId) => withTimeoutRetry(() => deps.rescanRecordings(deviceId)),
|
|
41324
|
+
reconcileAnalytics: (deviceId) => withTimeoutRetry(() => deps.reconcileAnalytics(deviceId)),
|
|
41325
|
+
onProgress: (update) => {
|
|
41326
|
+
job = {
|
|
41327
|
+
...job,
|
|
41328
|
+
total: update.total,
|
|
41329
|
+
completed: update.completed,
|
|
41330
|
+
currentDeviceId: update.deviceId,
|
|
41331
|
+
failed: update.failed,
|
|
41332
|
+
mediaDropped: update.mediaDropped,
|
|
41333
|
+
tracks: update.tracks,
|
|
41334
|
+
events: update.events
|
|
41335
|
+
};
|
|
41336
|
+
deps.onProgress?.(update);
|
|
41337
|
+
deps.log?.("pipeline disk reconcile camera", {
|
|
41338
|
+
deviceId: update.deviceId,
|
|
41339
|
+
completed: update.completed,
|
|
41340
|
+
total: update.total,
|
|
41341
|
+
failed: update.failed.length,
|
|
41342
|
+
mediaDropped: update.mediaDropped,
|
|
41343
|
+
tracks: update.tracks,
|
|
41344
|
+
events: update.events
|
|
41345
|
+
});
|
|
41346
|
+
}
|
|
41347
|
+
});
|
|
41348
|
+
job = {
|
|
41349
|
+
...job,
|
|
41350
|
+
state: "done",
|
|
41351
|
+
total: result.cameras + result.failed.length,
|
|
41352
|
+
completed: result.cameras + result.failed.length,
|
|
41353
|
+
currentDeviceId: null,
|
|
41354
|
+
failed: result.failed,
|
|
41355
|
+
mediaDropped: result.mediaDropped,
|
|
41356
|
+
tracks: result.tracks,
|
|
41357
|
+
events: result.events,
|
|
41358
|
+
finishedAtMs: now(),
|
|
41359
|
+
error: null
|
|
41360
|
+
};
|
|
41361
|
+
deps.log?.("pipeline disk reconcile", {
|
|
41362
|
+
cameras: result.cameras,
|
|
41363
|
+
failed: result.failed,
|
|
41364
|
+
mediaDropped: result.mediaDropped,
|
|
41365
|
+
tracks: result.tracks,
|
|
41366
|
+
events: result.events
|
|
41367
|
+
});
|
|
41368
|
+
} catch (err) {
|
|
41369
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
41370
|
+
job = {
|
|
41371
|
+
...job,
|
|
41372
|
+
state: "error",
|
|
41373
|
+
currentDeviceId: null,
|
|
41374
|
+
finishedAtMs: now(),
|
|
41375
|
+
error
|
|
41376
|
+
};
|
|
41377
|
+
deps.log?.("pipeline disk reconcile failed", { error });
|
|
41378
|
+
} finally {
|
|
41379
|
+
inFlight = null;
|
|
41380
|
+
}
|
|
41381
|
+
})();
|
|
41382
|
+
return job;
|
|
41383
|
+
};
|
|
41384
|
+
return {
|
|
41385
|
+
snapshot,
|
|
41386
|
+
start
|
|
41387
|
+
};
|
|
41388
|
+
}
|
|
41389
|
+
//#endregion
|
|
41390
|
+
//#region src/inference-device-model.ts
|
|
41391
|
+
/**
|
|
41392
|
+
* Per-device default object-detection model + deviceKey parsing for the
|
|
41393
|
+
* orchestrator's device-aware `getNodeInferenceDevices` view.
|
|
41394
|
+
*
|
|
41395
|
+
* This DUPLICATES the executor's per-device model resolution (P0-3:
|
|
41396
|
+
* `resolveDeviceEngine` + `MODEL_BY_CLASS` + the object-detection step's
|
|
41397
|
+
* `defaultModelIdByFormat` in `@camstack/addon-pipeline`). It is duplicated —
|
|
41398
|
+
* not imported — because cross-addon imports are forbidden (the orchestrator
|
|
41399
|
+
* and the detection-pipeline are separate addons; only tRPC crosses the
|
|
41400
|
+
* boundary). Keep this in sync with `default-detection-model.ts` /
|
|
41401
|
+
* `step-definitions.ts` if the executor's defaults change.
|
|
41402
|
+
*
|
|
41403
|
+
* The returned ids are honest catalog ids (verified present):
|
|
41404
|
+
* - `yolov9m-320-int8` — Intel NPU + iGPU (yolo26 does NOT compile on the NPU)
|
|
41405
|
+
* - `yolov9m-320` — Apple ANE (CoreML)
|
|
41406
|
+
* - `ssd-mobilenet-v2-coco-edgetpu` — Coral USB Edge TPU (tflite)
|
|
41407
|
+
* - `yolo26n` — CPU / CUDA (the object-detection step's universal
|
|
41408
|
+
* nano default)
|
|
41409
|
+
*
|
|
41410
|
+
* Do not promote the accelerated ids to 640. The evaluation in
|
|
41411
|
+
* `docs/benchmarks/pipeline-frame-model-eval.md` failed the 640 promotion
|
|
41412
|
+
* gates (0/3 miss recovered at the current threshold).
|
|
41413
|
+
*/
|
|
41414
|
+
/**
|
|
41415
|
+
* The always-on object-detection ROOT step id. A camera session's tracks all
|
|
41416
|
+
* originate from this detector, so a device whose engine format can't run it
|
|
41417
|
+
* cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
|
|
41418
|
+
* import is forbidden — this is the same duplication rationale as the model
|
|
41419
|
+
* defaults above).
|
|
41420
|
+
*/
|
|
41421
|
+
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
41422
|
+
/**
|
|
41423
|
+
* Build the camera-root capability predicate for a node from its live catalog:
|
|
41424
|
+
* `format → canHostCameraRoot`. A format can host a camera root iff the
|
|
41425
|
+
* catalog lists at least one object-detection model with a build for that
|
|
41426
|
+
* format — byte-for-byte the resolver's per-device skip-gate test for the root
|
|
41427
|
+
* step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
|
|
41428
|
+
* would ACTUALLY provision on it.
|
|
41429
|
+
*
|
|
41430
|
+
* Fails OPEN when the catalog has no object-detection slot at all (never
|
|
41431
|
+
* observed in production) so a malformed/empty catalog never strands every
|
|
41432
|
+
* device off the balancer. Pure + deterministic.
|
|
41433
|
+
*/
|
|
41434
|
+
function makeRootCapabilityGuard(catalog) {
|
|
41435
|
+
for (const slot of catalog.slots) {
|
|
41436
|
+
const objDet = slot.addons.find((a) => a.id === OBJECT_DETECTION_STEP_ID);
|
|
41437
|
+
if (objDet) return (format) => objDet.models.some((m) => Boolean(m.formats[format]));
|
|
41438
|
+
}
|
|
41439
|
+
return () => true;
|
|
41440
|
+
}
|
|
41441
|
+
/** Split a deviceKey (`<backend>:<device>`, or bare `cpu`) into its parts + format.
|
|
41442
|
+
* Format comes from the shared {@link deviceBackendToFormat} SSOT (`@camstack/types`)
|
|
41443
|
+
* — the previously-local `BACKEND_FORMAT` copy is gone (R3/node-F2). Used only for
|
|
41444
|
+
* STORED-ONLY keys (a configured device the live probe didn't return); a probed
|
|
41445
|
+
* device carries its own honest `format` from the descriptor. */
|
|
41446
|
+
function parseDeviceKey(deviceKey) {
|
|
41447
|
+
const colon = deviceKey.indexOf(":");
|
|
41448
|
+
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
41449
|
+
return {
|
|
41450
|
+
backend,
|
|
41451
|
+
device: colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey,
|
|
41452
|
+
format: deviceBackendToFormat(backend)
|
|
41453
|
+
};
|
|
41454
|
+
}
|
|
41455
|
+
/**
|
|
41456
|
+
* The object-detection model the executor defaults to for a deviceKey. Mirrors
|
|
41457
|
+
* the executor's `MODEL_BY_CLASS` classification (`classifyAccelerator`) plus
|
|
41458
|
+
* the tflite `defaultModelIdByFormat` for Coral. Never throws; unknown backends
|
|
41459
|
+
* fall back to the universal nano default (`yolo26n`).
|
|
41460
|
+
*/
|
|
41461
|
+
function defaultModelIdForDevice(deviceKey) {
|
|
41462
|
+
const { backend, device } = parseDeviceKey(deviceKey);
|
|
41463
|
+
if (backend === "openvino") {
|
|
41464
|
+
if (device === "cpu") return "yolo26n";
|
|
41465
|
+
return "yolov9m-320-int8";
|
|
41466
|
+
}
|
|
41467
|
+
if (backend === "edgetpu") return "ssd-mobilenet-v2-coco-edgetpu";
|
|
41468
|
+
if (backend === "coreml") return "yolov9m-320";
|
|
41469
|
+
return "yolo26n";
|
|
41470
|
+
}
|
|
41471
|
+
/**
|
|
41472
|
+
* Step-tree device jump (phase 1): validate every `steps[step].jumpDeviceKey`
|
|
41473
|
+
* manual override in a to-be-saved `inferenceDevices` map. A jump target MUST be
|
|
41474
|
+
* an enabled∧available device on the SAME node and DIFFERENT from the owning
|
|
41475
|
+
* device. `enabledAvailableKeys` is the effective enabled∧available set (from
|
|
41476
|
+
* `mergeInferenceDevices(probe, submitted)`) so an absent/unplugged/disabled
|
|
41477
|
+
* target is rejected honestly (an operator can't route a step onto a dead pool).
|
|
41478
|
+
* Returns the FIRST human-readable error, or `null` when every override is
|
|
41479
|
+
* valid. Pure + deterministic.
|
|
41480
|
+
*/
|
|
41481
|
+
function validateJumpTargets(inferenceDevices, enabledAvailableKeys) {
|
|
41482
|
+
for (const [deviceKey, entry] of Object.entries(inferenceDevices)) for (const [stepId, step] of Object.entries(entry.steps ?? {})) {
|
|
41483
|
+
const target = step.jumpDeviceKey;
|
|
41484
|
+
if (target === void 0) continue;
|
|
41485
|
+
if (target === deviceKey) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey pointing at its own device`;
|
|
41486
|
+
if (!enabledAvailableKeys.has(target)) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey "${target}" that is not an enabled, available device on this node`;
|
|
41487
|
+
}
|
|
41488
|
+
return null;
|
|
41489
|
+
}
|
|
41490
|
+
/**
|
|
41491
|
+
* Merge a node's live-probed inference devices with its stored per-device map.
|
|
41492
|
+
*
|
|
41493
|
+
* The default is **AUTO = all discovered ACCELERATORS enabled** (spec C2,
|
|
41494
|
+
* opt-OUT) with TWO deliberate exceptions, both **opt-IN** (default disabled):
|
|
41495
|
+
*
|
|
41496
|
+
* - **CPU**: `enumerateInferenceDevices` always emits a universal `cpu`
|
|
41497
|
+
* floor on every platform; auto-enabling it would let the balancer
|
|
41498
|
+
* round-robin ~1/N of sessions onto the slow CPU pool alongside the
|
|
41499
|
+
* NPU/iGPU/ANE. CPU stays the always-available FALLBACK (a node with no
|
|
41500
|
+
* eligible accelerator leaves `deviceKey` unset → the runner's default
|
|
41501
|
+
* pool, which is CPU), not a balanced target — matching the spec's "no
|
|
41502
|
+
* device eligible → fall back to CPU".
|
|
41503
|
+
* - **Coral Edge TPU (`edgetpu`)**: the standing rule since the Coral
|
|
41504
|
+
* executor landed is that it surfaces as selectable but is NEVER
|
|
41505
|
+
* auto-picked — it runs a DIFFERENT, weaker model family (tflite SSD
|
|
41506
|
+
* MobileNet, not the YOLO the other accelerators run), so silently
|
|
41507
|
+
* enrolling a plugged-in Coral changes detection QUALITY, not just
|
|
41508
|
+
* placement. The opt-OUT default did exactly that on 2026-08-01: a hub
|
|
41509
|
+
* Coral nobody enabled entered the session rotation and camera 615 spent
|
|
41510
|
+
* hours at 2.4fps failing tflite model resolution. An operator who wants
|
|
41511
|
+
* the Coral balanced opts it in explicitly (`enabled: true`).
|
|
41512
|
+
*
|
|
41513
|
+
* So: an NPU/iGPU/ANE accelerator with NO stored entry is `enabled:true`; a
|
|
41514
|
+
* CPU or edgetpu device with no stored entry is `enabled:false`; an explicit
|
|
41515
|
+
* stored `enabled` always wins (an operator can opt CPU/Coral in, or an
|
|
41516
|
+
* accelerator out). A stored-only key (configured but the probe did not
|
|
41517
|
+
* return it — removed/unplugged HW) keeps its stored `enabled` and surfaces
|
|
41518
|
+
* as `available:false`, so the UI still shows it.
|
|
41519
|
+
*
|
|
41520
|
+
* Pure + deterministic (sorted by key) — the single merge authority shared by
|
|
41521
|
+
* the `getNodeInferenceDevices` view and the dispatcher's eligible-device pick.
|
|
41522
|
+
*/
|
|
41523
|
+
function mergeInferenceDevices(probed, stored) {
|
|
41524
|
+
const probedByKey = new Map(probed.map((d) => [d.key, d]));
|
|
41525
|
+
const keys = new Set([...probedByKey.keys(), ...Object.keys(stored)]);
|
|
41526
|
+
const out = [];
|
|
41527
|
+
for (const key of Array.from(keys).toSorted()) {
|
|
41528
|
+
const descriptor = probedByKey.get(key);
|
|
41529
|
+
const opt = stored[key];
|
|
41530
|
+
const parsed = descriptor ?? parseDeviceKey(key);
|
|
41531
|
+
const weight = opt?.weight !== void 0 && opt.weight > 0 ? opt.weight : 1;
|
|
41532
|
+
const autoDefault = parsed.backend !== "cpu" && parsed.backend !== "edgetpu";
|
|
41533
|
+
out.push({
|
|
41534
|
+
key,
|
|
41535
|
+
backend: parsed.backend,
|
|
41536
|
+
device: parsed.device,
|
|
41537
|
+
format: parsed.format,
|
|
41538
|
+
available: descriptor?.available ?? false,
|
|
41539
|
+
enabled: opt?.enabled ?? autoDefault,
|
|
41540
|
+
weight,
|
|
41541
|
+
maxSessions: opt?.maxSessions ?? null,
|
|
41542
|
+
defaultModelId: defaultModelIdForDevice(key),
|
|
41543
|
+
...opt?.steps && Object.keys(opt.steps).length > 0 ? { steps: { ...opt.steps } } : {}
|
|
41544
|
+
});
|
|
41545
|
+
}
|
|
41546
|
+
return out;
|
|
41547
|
+
}
|
|
41548
|
+
/**
|
|
41549
|
+
* The per-device concurrent-session caps for a node as `deviceKey → maxSessions`
|
|
41550
|
+
* (only devices that carry an explicit cap; absent = unlimited). Fed to the
|
|
41551
|
+
* device balancer's `nodeCaps` so a device at its cap is skipped (audit F3).
|
|
41552
|
+
*/
|
|
41553
|
+
function inferenceDeviceCaps(stored) {
|
|
41554
|
+
const out = {};
|
|
41555
|
+
for (const [key, entry] of Object.entries(stored)) if (entry.maxSessions !== void 0 && entry.maxSessions > 0) out[key] = entry.maxSessions;
|
|
41556
|
+
return out;
|
|
41557
|
+
}
|
|
41558
|
+
/** Is this device the CPU fallback rather than a real accelerator? */
|
|
41559
|
+
function isCpuFallback(view) {
|
|
41560
|
+
return view.backend === "cpu";
|
|
41561
|
+
}
|
|
41562
|
+
function resolveInferenceDeviceEligibility(probed, stored, canRunRoot, isPoolUsable) {
|
|
41563
|
+
const eligible = {};
|
|
41564
|
+
const excluded = [];
|
|
41565
|
+
const merged = mergeInferenceDevices(probed, stored);
|
|
41566
|
+
const acceleratorServes = merged.some((d) => !isCpuFallback(d) && d.enabled && d.available && (!canRunRoot || canRunRoot(d.format)));
|
|
41567
|
+
for (const d of merged) {
|
|
41568
|
+
if (!d.enabled) {
|
|
41569
|
+
excluded.push({
|
|
41570
|
+
key: d.key,
|
|
41571
|
+
reason: "disabled",
|
|
41572
|
+
format: d.format
|
|
41573
|
+
});
|
|
41574
|
+
continue;
|
|
41575
|
+
}
|
|
41576
|
+
if (!d.available) {
|
|
41577
|
+
excluded.push({
|
|
41578
|
+
key: d.key,
|
|
41579
|
+
reason: "unavailable",
|
|
41580
|
+
format: d.format
|
|
41581
|
+
});
|
|
41582
|
+
continue;
|
|
41426
41583
|
}
|
|
41427
|
-
|
|
41428
|
-
|
|
41429
|
-
|
|
41430
|
-
|
|
41431
|
-
|
|
41432
|
-
} catch {}
|
|
41433
|
-
this.audioSubscriptions.delete(deviceId);
|
|
41434
|
-
this.deps.logger.info("lazy audio: window closed", {
|
|
41435
|
-
tags: { deviceId },
|
|
41436
|
-
meta: { reason }
|
|
41584
|
+
if (isPoolUsable && !isPoolUsable(d.key)) {
|
|
41585
|
+
excluded.push({
|
|
41586
|
+
key: d.key,
|
|
41587
|
+
reason: "unavailable",
|
|
41588
|
+
format: d.format
|
|
41437
41589
|
});
|
|
41438
|
-
|
|
41439
|
-
}
|
|
41440
|
-
/**
|
|
41441
|
-
* Audio teardown for one device — the audio half of `stopDetection`.
|
|
41442
|
-
* Tears down through the per-device lock so it can't race a concurrent
|
|
41443
|
-
* subscribe (which would re-store a handle this teardown never sees).
|
|
41444
|
-
*/
|
|
41445
|
-
async stopForDevice(deviceId) {
|
|
41446
|
-
await this.withAudioSubLock(deviceId, async () => {
|
|
41447
|
-
const unsub = this.audioSubscriptions.get(deviceId);
|
|
41448
|
-
if (unsub) {
|
|
41449
|
-
try {
|
|
41450
|
-
unsub();
|
|
41451
|
-
} catch {}
|
|
41452
|
-
this.audioSubscriptions.delete(deviceId);
|
|
41453
|
-
}
|
|
41454
|
-
});
|
|
41455
|
-
const lazyTimer = this.lazyAudioTeardownTimers.get(deviceId);
|
|
41456
|
-
if (lazyTimer) {
|
|
41457
|
-
clearTimeout(lazyTimer);
|
|
41458
|
-
this.lazyAudioTeardownTimers.delete(deviceId);
|
|
41590
|
+
continue;
|
|
41459
41591
|
}
|
|
41460
|
-
|
|
41461
|
-
|
|
41462
|
-
|
|
41463
|
-
|
|
41592
|
+
if (canRunRoot && !canRunRoot(d.format)) {
|
|
41593
|
+
excluded.push({
|
|
41594
|
+
key: d.key,
|
|
41595
|
+
reason: "cannot-host-camera-root",
|
|
41596
|
+
format: d.format
|
|
41597
|
+
});
|
|
41598
|
+
continue;
|
|
41464
41599
|
}
|
|
41465
|
-
|
|
41466
|
-
|
|
41467
|
-
|
|
41468
|
-
|
|
41469
|
-
|
|
41470
|
-
|
|
41471
|
-
|
|
41472
|
-
* (best-effort, error-swallowed) and DO NOT store. `protected` so
|
|
41473
|
-
* `audio-sub-lock.spec.ts`'s test subclass can assert the shutdown-guard
|
|
41474
|
-
* behavior without casts.
|
|
41475
|
-
*/
|
|
41476
|
-
storeAudioSub(deviceId, unsub) {
|
|
41477
|
-
if (this.audioShuttingDown) {
|
|
41478
|
-
try {
|
|
41479
|
-
unsub();
|
|
41480
|
-
} catch {}
|
|
41481
|
-
return;
|
|
41600
|
+
if (isCpuFallback(d) && acceleratorServes) {
|
|
41601
|
+
excluded.push({
|
|
41602
|
+
key: d.key,
|
|
41603
|
+
reason: "accelerator-preferred",
|
|
41604
|
+
format: d.format
|
|
41605
|
+
});
|
|
41606
|
+
continue;
|
|
41482
41607
|
}
|
|
41483
|
-
|
|
41484
|
-
}
|
|
41485
|
-
/**
|
|
41486
|
-
* Serialize an audio-subscription critical section per device. `fn` is
|
|
41487
|
-
* chained onto the device's current lock tail, so concurrent calls for the
|
|
41488
|
-
* SAME deviceId run sequentially (FIFO); different deviceIds never block
|
|
41489
|
-
* each other. Thin delegate onto the `audioSubLocks` `KeyedAsyncLock`
|
|
41490
|
-
* instance. `protected` so `audio-sub-lock.spec.ts`'s test subclass can
|
|
41491
|
-
* drive the lock without casts.
|
|
41492
|
-
*/
|
|
41493
|
-
withAudioSubLock(deviceId, fn) {
|
|
41494
|
-
return this.audioSubLocks.run(deviceId, fn);
|
|
41608
|
+
eligible[d.key] = d.weight;
|
|
41495
41609
|
}
|
|
41610
|
+
return {
|
|
41611
|
+
eligible,
|
|
41612
|
+
excluded
|
|
41613
|
+
};
|
|
41614
|
+
}
|
|
41615
|
+
/**
|
|
41616
|
+
* Join the merged device rows with the eligibility verdict so the UI can NAME
|
|
41617
|
+
* why an accelerator is not in play instead of leaving the operator to deduce
|
|
41618
|
+
* it from `enabled`/`available`.
|
|
41619
|
+
*
|
|
41620
|
+
* Deduction is not possible for two of the four reasons — `accelerator-preferred`
|
|
41621
|
+
* is a node-WIDE rule (a CPU row reads `enabled:true, available:true` and still
|
|
41622
|
+
* never gets a session, D215) and `cannot-host-camera-root` needs the node's
|
|
41623
|
+
* model catalog. Both live in {@link resolveInferenceDeviceEligibility}, so this
|
|
41624
|
+
* function only transports its answer; it never re-derives one.
|
|
41625
|
+
*
|
|
41626
|
+
* Pure; preserves `merged`'s order (sorted by key) and every other field.
|
|
41627
|
+
*/
|
|
41628
|
+
function annotateInferenceDeviceExclusions(merged, eligibility) {
|
|
41629
|
+
const reasonByKey = new Map(eligibility.excluded.map((e) => [e.key, e.reason]));
|
|
41630
|
+
return merged.map((view) => ({
|
|
41631
|
+
...view,
|
|
41632
|
+
exclusion: reasonByKey.get(view.key) ?? null
|
|
41633
|
+
}));
|
|
41634
|
+
}
|
|
41635
|
+
function resolveNodeInferenceUsability(eligibility) {
|
|
41636
|
+
const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
|
|
41637
|
+
const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
|
|
41638
|
+
return {
|
|
41639
|
+
usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
|
|
41640
|
+
unavailableKeys,
|
|
41641
|
+
eligibleKeys
|
|
41642
|
+
};
|
|
41643
|
+
}
|
|
41644
|
+
/**
|
|
41645
|
+
* Step-tree device jump (phase 1): the attach-payload roster of a node's
|
|
41646
|
+
* enabled∧available inference devices with the balancer knobs (`weight`,
|
|
41647
|
+
* `maxSessions`) the runner uses to AUTO-jump an enrichment step off a device
|
|
41648
|
+
* whose format can't run it. Built from the SAME `eligible` (deviceKey→weight)
|
|
41649
|
+
* and `caps` (deviceKey→maxSessions) the dispatcher already computes, so the
|
|
41650
|
+
* roster the runner sees exactly matches the balancer's candidate set. Sorted
|
|
41651
|
+
* by key for determinism. Populated onto `RunnerCameraConfig.inferenceDevices`
|
|
41652
|
+
* ONLY when a `deviceKey` is elected and there are ≥2 entries.
|
|
41653
|
+
*/
|
|
41654
|
+
function buildInferenceDeviceRoster(eligible, caps) {
|
|
41655
|
+
return Object.entries(eligible).map(([deviceKey, weight]) => ({
|
|
41656
|
+
deviceKey,
|
|
41657
|
+
weight: weight > 0 ? weight : 1,
|
|
41658
|
+
maxSessions: caps[deviceKey] ?? null
|
|
41659
|
+
})).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
|
|
41660
|
+
}
|
|
41661
|
+
//#endregion
|
|
41662
|
+
//#region src/node-inference-usability-mirror.ts
|
|
41663
|
+
var NodeInferenceUsabilityMirror = class {
|
|
41664
|
+
state = /* @__PURE__ */ new Map();
|
|
41496
41665
|
/**
|
|
41497
|
-
*
|
|
41498
|
-
*
|
|
41499
|
-
* `resolveDeviceSettings(deviceId)` method so the orchestrator does not
|
|
41500
|
-
* touch the audio-analyzer schema field names directly.
|
|
41666
|
+
* Fold one observation in and report whether the caller should act.
|
|
41667
|
+
* Never throws.
|
|
41501
41668
|
*/
|
|
41502
|
-
|
|
41503
|
-
const
|
|
41504
|
-
if (
|
|
41505
|
-
this.
|
|
41506
|
-
|
|
41507
|
-
|
|
41508
|
-
|
|
41509
|
-
|
|
41510
|
-
this.deps.logger.debug("audio subscribe skipped: audioMode=disabled", { tags: { deviceId } });
|
|
41511
|
-
return null;
|
|
41669
|
+
observe(nodeId, usable) {
|
|
41670
|
+
const prev = this.state.get(nodeId);
|
|
41671
|
+
if (usable) {
|
|
41672
|
+
this.state.set(nodeId, {
|
|
41673
|
+
usable: true,
|
|
41674
|
+
armed: false
|
|
41675
|
+
});
|
|
41676
|
+
return prev !== void 0 && !prev.usable ? "recovered" : null;
|
|
41512
41677
|
}
|
|
41513
|
-
if (
|
|
41514
|
-
this.
|
|
41678
|
+
if (prev === void 0) {
|
|
41679
|
+
this.state.set(nodeId, {
|
|
41680
|
+
usable: true,
|
|
41681
|
+
armed: true
|
|
41682
|
+
});
|
|
41515
41683
|
return null;
|
|
41516
41684
|
}
|
|
41517
|
-
|
|
41518
|
-
|
|
41519
|
-
|
|
41520
|
-
|
|
41521
|
-
tags: { deviceId },
|
|
41522
|
-
meta: {
|
|
41523
|
-
camStreamId: audioStream,
|
|
41524
|
-
brokerId: audioBrokerId,
|
|
41525
|
-
selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
|
|
41526
|
-
hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
|
|
41527
|
-
}
|
|
41685
|
+
if (!prev.usable) {
|
|
41686
|
+
this.state.set(nodeId, {
|
|
41687
|
+
usable: false,
|
|
41688
|
+
armed: true
|
|
41528
41689
|
});
|
|
41529
41690
|
return null;
|
|
41530
41691
|
}
|
|
41531
|
-
|
|
41532
|
-
|
|
41533
|
-
|
|
41692
|
+
if (!prev.armed) {
|
|
41693
|
+
this.state.set(nodeId, {
|
|
41694
|
+
usable: true,
|
|
41695
|
+
armed: true
|
|
41696
|
+
});
|
|
41534
41697
|
return null;
|
|
41535
41698
|
}
|
|
41536
|
-
|
|
41537
|
-
|
|
41538
|
-
|
|
41539
|
-
tags: { deviceId },
|
|
41540
|
-
meta: {
|
|
41541
|
-
audioNodeId,
|
|
41542
|
-
isRemote: isRemoteAudio
|
|
41543
|
-
}
|
|
41544
|
-
});
|
|
41545
|
-
const accumulator = new AudioWindowAccumulator(deviceId);
|
|
41546
|
-
const teardown = startAudioChunkPoller({
|
|
41547
|
-
api,
|
|
41548
|
-
brokerId: audioBrokerId,
|
|
41549
|
-
tag: "audio-analyzer",
|
|
41550
|
-
ownerNodeId: this.deps.ingestNode(),
|
|
41551
|
-
logger: this.deps.logger.withTags({ deviceId }),
|
|
41552
|
-
onChunk: async (chunk) => {
|
|
41553
|
-
this.deps.watchdogNote(deviceId, "audio");
|
|
41554
|
-
try {
|
|
41555
|
-
const audioChunkInput = accumulator.push(chunk);
|
|
41556
|
-
if (!audioChunkInput) return;
|
|
41557
|
-
const result = await api.audioAnalyzer.analyseChunk.mutate({
|
|
41558
|
-
chunk: audioChunkInput,
|
|
41559
|
-
settings,
|
|
41560
|
-
...isRemoteAudio ? { nodeId: audioNodeId } : {}
|
|
41561
|
-
});
|
|
41562
|
-
if (!result) return;
|
|
41563
|
-
const frame = buildAudioResultFrame(deviceId, result);
|
|
41564
|
-
this.deps.eventBus.emit({
|
|
41565
|
-
id: `audio-inference-${deviceId}-${Date.now()}`,
|
|
41566
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
41567
|
-
source: {
|
|
41568
|
-
type: "device",
|
|
41569
|
-
id: deviceId,
|
|
41570
|
-
nodeId: "hub",
|
|
41571
|
-
addonId: "pipeline-orchestrator",
|
|
41572
|
-
deviceId
|
|
41573
|
-
},
|
|
41574
|
-
category: EventCategory.PipelineAudioInferenceResult,
|
|
41575
|
-
data: {
|
|
41576
|
-
deviceId,
|
|
41577
|
-
frame,
|
|
41578
|
-
nodeId: "hub"
|
|
41579
|
-
}
|
|
41580
|
-
});
|
|
41581
|
-
} catch (err) {
|
|
41582
|
-
const msg = errMsg(err);
|
|
41583
|
-
this.deps.logger.error("Audio analysis failed", {
|
|
41584
|
-
tags: { deviceId },
|
|
41585
|
-
meta: { error: msg }
|
|
41586
|
-
});
|
|
41587
|
-
}
|
|
41588
|
-
}
|
|
41699
|
+
this.state.set(nodeId, {
|
|
41700
|
+
usable: false,
|
|
41701
|
+
armed: true
|
|
41589
41702
|
});
|
|
41590
|
-
|
|
41591
|
-
return () => {
|
|
41592
|
-
teardown();
|
|
41593
|
-
accumulator.reset();
|
|
41594
|
-
};
|
|
41703
|
+
return "became-unusable";
|
|
41595
41704
|
}
|
|
41596
|
-
/**
|
|
41597
|
-
|
|
41598
|
-
|
|
41599
|
-
|
|
41600
|
-
|
|
41601
|
-
|
|
41602
|
-
|
|
41603
|
-
|
|
41604
|
-
|
|
41605
|
-
|
|
41606
|
-
|
|
41607
|
-
this.
|
|
41705
|
+
/** Can this node be given cameras? Unknown nodes answer YES. */
|
|
41706
|
+
isUsable(nodeId) {
|
|
41707
|
+
return this.state.get(nodeId)?.usable ?? true;
|
|
41708
|
+
}
|
|
41709
|
+
/** Nodes currently excluded — for the placement log and diagnostics. */
|
|
41710
|
+
unusableNodeIds() {
|
|
41711
|
+
const out = [];
|
|
41712
|
+
for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
|
|
41713
|
+
return out.toSorted();
|
|
41714
|
+
}
|
|
41715
|
+
forget(nodeId) {
|
|
41716
|
+
this.state.delete(nodeId);
|
|
41608
41717
|
}
|
|
41718
|
+
reset() {
|
|
41719
|
+
this.state.clear();
|
|
41720
|
+
}
|
|
41721
|
+
};
|
|
41722
|
+
//#endregion
|
|
41723
|
+
//#region src/inference-device-usability-mirror.ts
|
|
41724
|
+
/**
|
|
41725
|
+
* InferenceDeviceUsabilityMirror — "can this NODE's THIS DEVICE still infer?",
|
|
41726
|
+
* kept off the placement path.
|
|
41727
|
+
*
|
|
41728
|
+
* The per-`(nodeId, deviceKey)` twin of {@link NodeInferenceUsabilityMirror},
|
|
41729
|
+
* and deliberately not a second mechanism: it composes the key and delegates
|
|
41730
|
+
* every decision to that class, so the arm/apply reluctance D49 pinned lives in
|
|
41731
|
+
* exactly one implementation and cannot drift between the node tier and the
|
|
41732
|
+
* device tier.
|
|
41733
|
+
*
|
|
41734
|
+
* ## Why this tier had to exist
|
|
41735
|
+
*
|
|
41736
|
+
* The node tier already answers "does this node have ANY usable accelerator".
|
|
41737
|
+
* On 2026-08-25 the hub's answer was, correctly, yes — its NPU was healthy the
|
|
41738
|
+
* whole time. What died was one DEVICE, `openvino:gpu`, and nothing anywhere
|
|
41739
|
+
* asked that question: the per-dispatch capability gate is keyed on model
|
|
41740
|
+
* FORMAT and `openvino:gpu` and `openvino:npu` share the format `openvino`, so
|
|
41741
|
+
* it is blind between them by construction. The balancer kept rotating cameras
|
|
41742
|
+
* onto the dead pool — 20 sessions in 20 minutes, every one `tieBreak:
|
|
41743
|
+
* "rotation"` — for 31 hours.
|
|
41744
|
+
*
|
|
41745
|
+
* ## Why a mirror and not the event
|
|
41746
|
+
*
|
|
41747
|
+
* D49, verbatim: *a one-shot event never gates on a fallible read*. The gate is
|
|
41748
|
+
* this in-memory mirror, refreshed off the event path by the same
|
|
41749
|
+
* `resolveEligibleInferenceDevices` the dispatcher already runs (plus the
|
|
41750
|
+
* session controller's background refresher). The consequences that buys:
|
|
41751
|
+
*
|
|
41752
|
+
* - **A read that fails changes nothing.** The caller folds in an observation
|
|
41753
|
+
* only when it HAS one; an unreachable node, a version-skewed executor or a
|
|
41754
|
+
* rejected RPC never reaches {@link observe}, so the previous verdict
|
|
41755
|
+
* stands. This is the whole reason the health read is specified as
|
|
41756
|
+
* "synchronous over in-memory state, never throws for its own reasons": an
|
|
41757
|
+
* empty answer must mean *nothing is refused*, not *I could not tell*.
|
|
41758
|
+
* - **Excluding a healthy device needs a second, consecutive read.** Exclusion
|
|
41759
|
+
* is the direction that DESTROYS work — it strands an accelerator that may
|
|
41760
|
+
* be perfectly fine — so one bad observation only ARMS.
|
|
41761
|
+
* - **Re-admitting is immediate and unconditional.** One good observation puts
|
|
41762
|
+
* the device straight back. Being slow to exclude costs some wasted
|
|
41763
|
+
* inference attempts; being slow to re-admit costs an idle accelerator and a
|
|
41764
|
+
* node that looks broken.
|
|
41765
|
+
*/
|
|
41766
|
+
/**
|
|
41767
|
+
* NUL — impossible in both a Moleculer node id and a `<backend>:<device>` key,
|
|
41768
|
+
* so the composite key can never be ambiguous. A separator that CAN occur in
|
|
41769
|
+
* either half makes two distinct pairs collide, and a collision here silently
|
|
41770
|
+
* excludes an accelerator nobody reported.
|
|
41771
|
+
*/
|
|
41772
|
+
var SEPARATOR = "\0";
|
|
41773
|
+
var InferenceDeviceUsabilityMirror = class {
|
|
41774
|
+
/** The one implementation of the arm/apply state machine (D49). */
|
|
41775
|
+
mirror = new NodeInferenceUsabilityMirror();
|
|
41609
41776
|
/**
|
|
41610
|
-
*
|
|
41611
|
-
*
|
|
41612
|
-
* session/reconcile/load-shed clears — subscription teardown + lock clear
|
|
41613
|
-
* + assignment-map clears) into one call. Safe to combine: both blocks
|
|
41614
|
-
* are synchronous with no interleaved `await`, and every original
|
|
41615
|
-
* statement between them (`sessionRegistry.clear()`,
|
|
41616
|
-
* `cameraFpsMap.clear()`, `remoteHealthAttempts.clear()`,
|
|
41617
|
-
* `loadShedState.clear()`, `loadShedResumeTimer` cleanup) touches state
|
|
41618
|
-
* fully disjoint from anything audio — so their relative order to each
|
|
41619
|
-
* other is unaffected, and the audio-internal order (timers →
|
|
41620
|
-
* subscriptions → lock → assignment maps) is reproduced exactly.
|
|
41777
|
+
* Fold one observation in and report whether the caller should act.
|
|
41778
|
+
* Never throws.
|
|
41621
41779
|
*/
|
|
41622
|
-
|
|
41623
|
-
|
|
41624
|
-
|
|
41625
|
-
|
|
41626
|
-
|
|
41627
|
-
|
|
41628
|
-
|
|
41629
|
-
|
|
41630
|
-
|
|
41631
|
-
this.
|
|
41632
|
-
|
|
41633
|
-
|
|
41780
|
+
observe(nodeId, deviceKey, usable) {
|
|
41781
|
+
return this.mirror.observe(`${nodeId}${SEPARATOR}${deviceKey}`, usable);
|
|
41782
|
+
}
|
|
41783
|
+
/** Can the balancer put a session on this device? Unknown pairs answer YES. */
|
|
41784
|
+
isUsable(nodeId, deviceKey) {
|
|
41785
|
+
return this.mirror.isUsable(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
41786
|
+
}
|
|
41787
|
+
/** Pairs currently excluded — for the placement log and diagnostics. */
|
|
41788
|
+
unusableDevices() {
|
|
41789
|
+
return this.mirror.unusableNodeIds().map((composite) => {
|
|
41790
|
+
const at = composite.indexOf(SEPARATOR);
|
|
41791
|
+
return {
|
|
41792
|
+
nodeId: composite.slice(0, at),
|
|
41793
|
+
deviceKey: composite.slice(at + 1)
|
|
41794
|
+
};
|
|
41795
|
+
});
|
|
41796
|
+
}
|
|
41797
|
+
/** The excluded device keys on ONE node. */
|
|
41798
|
+
unusableDeviceKeys(nodeId) {
|
|
41799
|
+
return this.unusableDevices().filter((d) => d.nodeId === nodeId).map((d) => d.deviceKey);
|
|
41800
|
+
}
|
|
41801
|
+
forget(nodeId, deviceKey) {
|
|
41802
|
+
this.mirror.forget(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
41803
|
+
}
|
|
41804
|
+
reset() {
|
|
41805
|
+
this.mirror.reset();
|
|
41634
41806
|
}
|
|
41635
41807
|
};
|
|
41808
|
+
/**
|
|
41809
|
+
* Fold ONE node's health answer into the mirror and return what changed.
|
|
41810
|
+
*
|
|
41811
|
+
* This is the whole reading discipline, in one place, because both halves of it
|
|
41812
|
+
* are easy to get subtly wrong and neither failure is visible in a log:
|
|
41813
|
+
*
|
|
41814
|
+
* - **`unhealthy === null` — the read FAILED — changes nothing.** Not a single
|
|
41815
|
+
* entry is touched. An unreachable node, a version-skewed executor or a
|
|
41816
|
+
* rejected RPC must be distinguishable from "asked, nothing is refused", or
|
|
41817
|
+
* a flaky link silently re-admits a dead accelerator (D49).
|
|
41818
|
+
* - **Every device the node HAS is observed**, not merely the refused ones.
|
|
41819
|
+
* The first draft observed `refused ∪ already-excluded`, which omits exactly
|
|
41820
|
+
* the devices the mirror has ARMED — so their disarming good read never
|
|
41821
|
+
* arrived and two bad reads an HOUR apart, with a hundred healthy ones
|
|
41822
|
+
* between them, excluded a working accelerator. "Consecutive" is only a
|
|
41823
|
+
* property if the good observations are delivered.
|
|
41824
|
+
*
|
|
41825
|
+
* Pure with respect to everything except `mirror`, and never throws — it is
|
|
41826
|
+
* called from the dispatcher's own read path.
|
|
41827
|
+
*/
|
|
41828
|
+
function foldInferenceDeviceHealth(mirror, nodeId, unhealthy, present) {
|
|
41829
|
+
if (unhealthy === null) return [];
|
|
41830
|
+
const refused = new Set(unhealthy);
|
|
41831
|
+
const observed = new Set([
|
|
41832
|
+
...present,
|
|
41833
|
+
...refused,
|
|
41834
|
+
...mirror.unusableDeviceKeys(nodeId)
|
|
41835
|
+
]);
|
|
41836
|
+
const changes = [];
|
|
41837
|
+
for (const deviceKey of observed) {
|
|
41838
|
+
const transition = mirror.observe(nodeId, deviceKey, !refused.has(deviceKey));
|
|
41839
|
+
if (transition !== null) changes.push({
|
|
41840
|
+
deviceKey,
|
|
41841
|
+
transition
|
|
41842
|
+
});
|
|
41843
|
+
}
|
|
41844
|
+
return changes;
|
|
41845
|
+
}
|
|
41636
41846
|
//#endregion
|
|
41637
41847
|
//#region src/load-balancer.ts
|
|
41638
41848
|
/**
|
|
@@ -43568,6 +43778,90 @@ function applyDeviceProvisioning(steps, base, override) {
|
|
|
43568
43778
|
});
|
|
43569
43779
|
}
|
|
43570
43780
|
//#endregion
|
|
43781
|
+
//#region src/device-activity-source.ts
|
|
43782
|
+
/** The source's own name on the wire. One constant, used by every emit + gate. */
|
|
43783
|
+
var DEVICE_ACTIVITY_SOURCE = "device-activity";
|
|
43784
|
+
var DeviceActivitySource = class {
|
|
43785
|
+
#deps;
|
|
43786
|
+
#devices = /* @__PURE__ */ new Map();
|
|
43787
|
+
constructor(deps) {
|
|
43788
|
+
this.#deps = deps;
|
|
43789
|
+
}
|
|
43790
|
+
/**
|
|
43791
|
+
* A `recording-signal` level landed for a device. Idempotent in the level:
|
|
43792
|
+
* only a CHANGE emits an edge, and the sustain tick — not a repeated push —
|
|
43793
|
+
* is what keeps the session open.
|
|
43794
|
+
*/
|
|
43795
|
+
onSignalLevel(deviceId, active, reason, atMs) {
|
|
43796
|
+
const entry = this.#devices.get(deviceId) ?? {
|
|
43797
|
+
active: false,
|
|
43798
|
+
timer: null
|
|
43799
|
+
};
|
|
43800
|
+
const wasActive = entry.active;
|
|
43801
|
+
entry.active = active;
|
|
43802
|
+
this.#devices.set(deviceId, entry);
|
|
43803
|
+
if (active === wasActive) return;
|
|
43804
|
+
if (active) {
|
|
43805
|
+
this.#deps.logger.info("device-activity: the device reports it is working", {
|
|
43806
|
+
tags: { deviceId },
|
|
43807
|
+
meta: { reason }
|
|
43808
|
+
});
|
|
43809
|
+
this.#emit(deviceId, true, atMs);
|
|
43810
|
+
this.#arm(deviceId, entry);
|
|
43811
|
+
return;
|
|
43812
|
+
}
|
|
43813
|
+
this.#clear(entry);
|
|
43814
|
+
this.#deps.logger.info("device-activity: the device reports it has stopped", {
|
|
43815
|
+
tags: { deviceId },
|
|
43816
|
+
meta: { reason }
|
|
43817
|
+
});
|
|
43818
|
+
this.#emit(deviceId, false, atMs);
|
|
43819
|
+
}
|
|
43820
|
+
/** Drop every timer (addon teardown). The mirror goes with the instance. */
|
|
43821
|
+
stop() {
|
|
43822
|
+
for (const entry of this.#devices.values()) this.#clear(entry);
|
|
43823
|
+
this.#devices.clear();
|
|
43824
|
+
}
|
|
43825
|
+
#arm(deviceId, entry) {
|
|
43826
|
+
this.#clear(entry);
|
|
43827
|
+
const sustainMs = this.#deps.sustainMsFor(deviceId);
|
|
43828
|
+
const timer = setInterval(() => {
|
|
43829
|
+
const current = this.#devices.get(deviceId);
|
|
43830
|
+
if (!current || !current.active) {
|
|
43831
|
+
this.#clear(current ?? entry);
|
|
43832
|
+
return;
|
|
43833
|
+
}
|
|
43834
|
+
this.#emit(deviceId, true, Date.now());
|
|
43835
|
+
}, sustainMs);
|
|
43836
|
+
timer.unref?.();
|
|
43837
|
+
entry.timer = timer;
|
|
43838
|
+
}
|
|
43839
|
+
#clear(entry) {
|
|
43840
|
+
if (entry.timer === null) return;
|
|
43841
|
+
clearInterval(entry.timer);
|
|
43842
|
+
entry.timer = null;
|
|
43843
|
+
}
|
|
43844
|
+
/**
|
|
43845
|
+
* Emit, unless this camera does not carry the source. A suppressed emit is
|
|
43846
|
+
* SAID — an operator who never ticked the box and an addon that is quietly
|
|
43847
|
+
* broken look identical from the outside otherwise.
|
|
43848
|
+
*/
|
|
43849
|
+
#emit(deviceId, detected, timestamp) {
|
|
43850
|
+
const sources = this.#deps.motionSourcesFor(deviceId);
|
|
43851
|
+
if (sources === null || !sources.includes("device-activity")) {
|
|
43852
|
+
this.#deps.logger.debug("device-activity: level not forwarded — the camera does not list `device-activity`", {
|
|
43853
|
+
tags: { deviceId },
|
|
43854
|
+
meta: {
|
|
43855
|
+
detected,
|
|
43856
|
+
sources: sources ?? "no-active-detection"
|
|
43857
|
+
}
|
|
43858
|
+
});
|
|
43859
|
+
return;
|
|
43860
|
+
}
|
|
43861
|
+
this.#deps.emitMotion(deviceId, detected, timestamp);
|
|
43862
|
+
}
|
|
43863
|
+
};
|
|
43864
|
+
//#endregion
|
|
43571
43865
|
//#region src/device-detection-settings.ts
|
|
43572
43866
|
/** Read a required string leaf out of the hydrated `flat` schema values. */
|
|
43573
43867
|
function mustString(flat, deviceId, key) {
|
|
@@ -43593,6 +43887,32 @@ function numberOrDefault(flat, key) {
|
|
|
43593
43887
|
const dflt = uiField && "default" in uiField ? uiField.default : void 0;
|
|
43594
43888
|
return typeof dflt === "number" ? dflt : 0;
|
|
43595
43889
|
}
|
|
43890
|
+
/** The activity rate from the hydrated store, or the shipped default. */
|
|
43891
|
+
function activityFps(flat) {
|
|
43892
|
+
const v = flat["activityDetectionFps"];
|
|
43893
|
+
return typeof v === "number" && v > 0 ? v : 1;
|
|
43894
|
+
}
|
|
43895
|
+
/**
|
|
43896
|
+
* The detection rate a session opens at, given WHAT OPENED IT.
|
|
43897
|
+
*
|
|
43898
|
+
* A CAP, not a set: `min(cameraRate, activityRate)`. A camera already slower
|
|
43899
|
+
* than the activity rate stays slower, so lowering the global rate keeps
|
|
43900
|
+
* working. Why this lever and not the other two: a per-device `detectionFps`
|
|
43901
|
+
* would also slow the sessions a real motion trigger opens on the same camera,
|
|
43902
|
+
* and it becomes silently wrong the day the device gains a second trigger; a
|
|
43903
|
+
* per-SOURCE rate table would have to reach the runner and be re-applied
|
|
43904
|
+
* whenever the source holding the session changes, which the attach-time config
|
|
43905
|
+
* cannot express. Scoping it to the session's TRIGGER puts the number exactly
|
|
43906
|
+
* where its justification lives.
|
|
43907
|
+
*
|
|
43908
|
+
* Applies to the session the activity level OPENS. A session already open at
|
|
43909
|
+
* the camera rate when the level rises is not re-attached to slow it down —
|
|
43910
|
+
* re-attaching a live session to change one number costs a decode restart.
|
|
43911
|
+
*/
|
|
43912
|
+
function detectionFpsForTrigger(config, trigger) {
|
|
43913
|
+
if (trigger !== "device-activity") return config.detectionFps;
|
|
43914
|
+
return Math.min(config.detectionFps, config.activityDetectionFps ?? 1);
|
|
43915
|
+
}
|
|
43596
43916
|
/**
|
|
43597
43917
|
* Pure decision/derivation half of the former `resolveDeviceDetectionSettings`.
|
|
43598
43918
|
* Given the I/O-gathered raw materials, resolves every operator-wins →
|
|
@@ -43602,14 +43922,19 @@ function numberOrDefault(flat, key) {
|
|
|
43602
43922
|
* the original method's "narrowing failed" catch.
|
|
43603
43923
|
*/
|
|
43604
43924
|
function resolveDetectionSettings(input) {
|
|
43605
|
-
const { deviceId, raw, flat, features, hasOnboardMotion, pipelineEnabled, motionDetectionEnabled } = input;
|
|
43925
|
+
const { deviceId, raw, flat, features, hasOnboardMotion, hasActivitySignal, pipelineEnabled, motionDetectionEnabled } = input;
|
|
43606
43926
|
const profile = resolveDeviceProfile(features);
|
|
43607
43927
|
const userMotionSources = raw["motionSources"];
|
|
43608
43928
|
let motionSources;
|
|
43609
43929
|
if (userMotionSources !== void 0) motionSources = MotionSourcesSchema.parse(userMotionSources);
|
|
43610
|
-
else
|
|
43611
|
-
|
|
43612
|
-
|
|
43930
|
+
else {
|
|
43931
|
+
let defaulted;
|
|
43932
|
+
if (hasOnboardMotion) defaulted = ["onboard"];
|
|
43933
|
+
else if (hasActivitySignal) defaulted = [];
|
|
43934
|
+
else if (profile && features.includes(DeviceFeature.BatteryOperated)) defaulted = [];
|
|
43935
|
+
else defaulted = MotionSourcesSchema.parse(flat["motionSources"]);
|
|
43936
|
+
motionSources = hasActivitySignal ? [...defaulted, DEVICE_ACTIVITY_SOURCE] : defaulted;
|
|
43937
|
+
}
|
|
43613
43938
|
const userDetectionMode = raw["detectionMode"];
|
|
43614
43939
|
const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
|
|
43615
43940
|
const userAudioMode = raw["audioMode"];
|
|
@@ -43628,6 +43953,7 @@ function resolveDetectionSettings(input) {
|
|
|
43628
43953
|
detectionStreamProfile: mustString(flat, deviceId, "detectionStreamProfile"),
|
|
43629
43954
|
motionFps: numberOrDefault(flat, "motionFps"),
|
|
43630
43955
|
detectionFps: numberOrDefault(flat, "detectionFps"),
|
|
43956
|
+
activityDetectionFps: activityFps(flat),
|
|
43631
43957
|
motionCooldownMs: numberOrDefault(flat, "motionCooldownMs"),
|
|
43632
43958
|
maxSessionHoldMs: numberOrDefault(flat, "maxSessionHoldMs"),
|
|
43633
43959
|
audioMotionWindowMs: numberOrDefault(flat, "audioMotionWindowMs"),
|
|
@@ -43684,6 +44010,7 @@ function buildDetectionConfigFromInputs(resolved, assigned) {
|
|
|
43684
44010
|
detectionStreamId: detectionCamStreamId,
|
|
43685
44011
|
motionFps: resolved.motionFps,
|
|
43686
44012
|
detectionFps: resolved.detectionFps,
|
|
44013
|
+
activityDetectionFps: resolved.activityDetectionFps,
|
|
43687
44014
|
motionCooldownMs: resolved.motionCooldownMs,
|
|
43688
44015
|
maxSessionHoldMs: resolved.maxSessionHoldMs,
|
|
43689
44016
|
audioMotionWindowMs: resolved.audioMotionWindowMs,
|
|
@@ -43709,6 +44036,7 @@ function detectionConfigEquals(a, b) {
|
|
|
43709
44036
|
if (a.detectionStreamId !== b.detectionStreamId) return false;
|
|
43710
44037
|
if (a.motionFps !== b.motionFps) return false;
|
|
43711
44038
|
if (a.detectionFps !== b.detectionFps) return false;
|
|
44039
|
+
if (a.activityDetectionFps !== b.activityDetectionFps) return false;
|
|
43712
44040
|
if (a.motionCooldownMs !== b.motionCooldownMs) return false;
|
|
43713
44041
|
if (a.maxSessionHoldMs !== b.maxSessionHoldMs) return false;
|
|
43714
44042
|
if (a.audioMotionWindowMs !== b.audioMotionWindowMs) return false;
|
|
@@ -44335,6 +44663,7 @@ var DetectionWiringController = class {
|
|
|
44335
44663
|
try {
|
|
44336
44664
|
const features = await this.lookupDeviceFeatures(deviceId);
|
|
44337
44665
|
const hasOnboardMotion = raw["motionSources"] === void 0 ? await this.deps.deviceHasOnboardMotionCap(deviceId) : false;
|
|
44666
|
+
const hasActivitySignal = raw["motionSources"] === void 0 ? await this.deps.deviceHasActivitySignalCap(deviceId) : false;
|
|
44338
44667
|
const pipelineEnabled = await this.isDetectionPipelineActive(deviceId);
|
|
44339
44668
|
const motionDetectionEnabled = await this.isMotionDetectionActive(deviceId);
|
|
44340
44669
|
return resolveDetectionSettings({
|
|
@@ -44343,6 +44672,7 @@ var DetectionWiringController = class {
|
|
|
44343
44672
|
flat,
|
|
44344
44673
|
features,
|
|
44345
44674
|
hasOnboardMotion,
|
|
44675
|
+
hasActivitySignal,
|
|
44346
44676
|
pipelineEnabled,
|
|
44347
44677
|
motionDetectionEnabled
|
|
44348
44678
|
});
|
|
@@ -44948,9 +45278,10 @@ var DeviceConfigContributions = class {
|
|
|
44948
45278
|
const schema = this.deps.deviceSettingsSchema();
|
|
44949
45279
|
if (!schema) return null;
|
|
44950
45280
|
const hasOnboardMotion = await this.deps.deviceHasOnboardMotionCap(input.deviceId);
|
|
44951
|
-
const
|
|
45281
|
+
const hasActivitySignal = await this.deps.deviceHasActivitySignalCap(input.deviceId);
|
|
45282
|
+
const rawWithDefaults = raw["motionSources"] === void 0 && (hasOnboardMotion || hasActivitySignal) ? {
|
|
44952
45283
|
...raw,
|
|
44953
|
-
motionSources: ["onboard"]
|
|
45284
|
+
motionSources: [...hasOnboardMotion ? ["onboard"] : [], ...hasActivitySignal ? ["device-activity"] : []]
|
|
44954
45285
|
} : raw;
|
|
44955
45286
|
const baseSections = hydrateSchema({
|
|
44956
45287
|
...schema,
|
|
@@ -47770,7 +48101,8 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47770
48101
|
if (!isEvent(event, EventCategory.MotionOnMotionChanged)) return;
|
|
47771
48102
|
const { deviceId, detected, timestamp } = event.data;
|
|
47772
48103
|
if (typeof deviceId !== "number") return;
|
|
47773
|
-
|
|
48104
|
+
const parsedSource = MotionSourceEnum.safeParse(event.data.source);
|
|
48105
|
+
deps.handleSessionMotion(deviceId, detected, typeof timestamp === "number" ? timestamp : void 0, parsedSource.success ? parsedSource.data : void 0).catch((err) => {
|
|
47774
48106
|
deps.logger.warn("session motion handler failed", {
|
|
47775
48107
|
tags: { deviceId },
|
|
47776
48108
|
meta: {
|
|
@@ -47792,8 +48124,47 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47792
48124
|
const deviceId = event.source.deviceId;
|
|
47793
48125
|
if (typeof deviceId === "number") deps.noteWatchdogSignal(deviceId, "motion");
|
|
47794
48126
|
});
|
|
48127
|
+
const activitySource = new DeviceActivitySource({
|
|
48128
|
+
logger: deps.logger,
|
|
48129
|
+
emitMotion: (deviceId, detected, timestamp) => {
|
|
48130
|
+
if (isTornDown) return;
|
|
48131
|
+
deps.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, {
|
|
48132
|
+
type: "device",
|
|
48133
|
+
id: deviceId,
|
|
48134
|
+
deviceId
|
|
48135
|
+
}, {
|
|
48136
|
+
deviceId,
|
|
48137
|
+
detected,
|
|
48138
|
+
timestamp,
|
|
48139
|
+
source: DEVICE_ACTIVITY_SOURCE
|
|
48140
|
+
}));
|
|
48141
|
+
},
|
|
48142
|
+
motionSourcesFor: (deviceId) => deps.getActiveDetectionConfig(deviceId)?.motionSources ?? null,
|
|
48143
|
+
sustainMsFor: (deviceId) => {
|
|
48144
|
+
const cooldownMs = deps.getActiveDetectionConfig(deviceId)?.motionCooldownMs;
|
|
48145
|
+
return Math.max(1e3, Math.floor((cooldownMs ?? 3e4) / 2));
|
|
48146
|
+
}
|
|
48147
|
+
});
|
|
48148
|
+
const unsubDeviceActivity = deps.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
48149
|
+
const data = event.data;
|
|
48150
|
+
if (typeof data !== "object" || data === null) return;
|
|
48151
|
+
if (data["capName"] !== recordingSignalCapability.name) return;
|
|
48152
|
+
const deviceId = data["deviceId"];
|
|
48153
|
+
if (typeof deviceId !== "number") return;
|
|
48154
|
+
const level = RecordingSignalStatusSchema.safeParse(data["slice"]);
|
|
48155
|
+
if (!level.success) {
|
|
48156
|
+
deps.logger.warn("device-activity: signal slice does not parse — ignored", {
|
|
48157
|
+
tags: { deviceId },
|
|
48158
|
+
meta: { slice: data["slice"] }
|
|
48159
|
+
});
|
|
48160
|
+
return;
|
|
48161
|
+
}
|
|
48162
|
+
const atMs = event.timestamp instanceof Date ? event.timestamp.getTime() : Date.now();
|
|
48163
|
+
activitySource.onSignalLevel(deviceId, level.data.active, level.data.reason, atMs);
|
|
48164
|
+
});
|
|
47795
48165
|
return () => {
|
|
47796
48166
|
isTornDown = true;
|
|
48167
|
+
activitySource.stop();
|
|
47797
48168
|
for (const t of profileSlotTimers.values()) clearTimeout(t);
|
|
47798
48169
|
profileSlotTimers.clear();
|
|
47799
48170
|
unsubDeviceRegistered();
|
|
@@ -47808,6 +48179,7 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47808
48179
|
unsubSessionMotion();
|
|
47809
48180
|
unsubFrameTracked();
|
|
47810
48181
|
unsubMotionAnalysis();
|
|
48182
|
+
unsubDeviceActivity();
|
|
47811
48183
|
};
|
|
47812
48184
|
}
|
|
47813
48185
|
//#endregion
|
|
@@ -50567,7 +50939,7 @@ var SessionDispatchController = class {
|
|
|
50567
50939
|
* standing attach, so `hasStandingAttach` stays true for them and
|
|
50568
50940
|
* `decideSessionAction` still returns `'ignore'`.
|
|
50569
50941
|
*/
|
|
50570
|
-
async handleSessionMotion(deviceId, detected, emittedAt) {
|
|
50942
|
+
async handleSessionMotion(deviceId, detected, emittedAt, trigger) {
|
|
50571
50943
|
const receivedAt = Date.now();
|
|
50572
50944
|
const busLagMs = emittedAt !== void 0 ? receivedAt - emittedAt : void 0;
|
|
50573
50945
|
const config = this.deps.getActiveDetectionConfig(deviceId);
|
|
@@ -50602,7 +50974,7 @@ var SessionDispatchController = class {
|
|
|
50602
50974
|
return;
|
|
50603
50975
|
}
|
|
50604
50976
|
this.activeRefireCountByDevice.delete(deviceId);
|
|
50605
|
-
await this.dispatchDetectionSession(deviceId, cur);
|
|
50977
|
+
await this.dispatchDetectionSession(deviceId, cur, trigger);
|
|
50606
50978
|
if (this.sessionRegistry.has(deviceId)) this.scheduleSessionTeardown(deviceId, cooldownMs);
|
|
50607
50979
|
const doneAt = Date.now();
|
|
50608
50980
|
this.deps.logger.info("session motion → attach latency", {
|
|
@@ -50641,7 +51013,7 @@ var SessionDispatchController = class {
|
|
|
50641
51013
|
* `dispatchCamera`) avoids any risk of changing that already-live
|
|
50642
51014
|
* standing-camera path.
|
|
50643
51015
|
*/
|
|
50644
|
-
async dispatchDetectionSession(deviceId, config) {
|
|
51016
|
+
async dispatchDetectionSession(deviceId, config, trigger) {
|
|
50645
51017
|
const log = this.deps.logger.withTags({ deviceId });
|
|
50646
51018
|
await this.deps.reconcilePlacementFromRunners();
|
|
50647
51019
|
const preferredAgent = await this.deps.readPipelinePin(deviceId);
|
|
@@ -50697,13 +51069,19 @@ var SessionDispatchController = class {
|
|
|
50697
51069
|
const steps = applyDeviceProvisioning(pipelineConfig.steps, deviceBase, deviceOverride);
|
|
50698
51070
|
const inferenceDevices = deviceKey && Object.keys(enabledDevices).length >= 2 ? buildInferenceDeviceRoster(enabledDevices, deviceCaps) : void 0;
|
|
50699
51071
|
const zones = await this.deps.listZones(deviceId);
|
|
51072
|
+
const sessionDetectionFps = detectionFpsForTrigger(config, trigger);
|
|
51073
|
+
if (sessionDetectionFps !== config.detectionFps) log.info("session opened at the device-activity rate", { meta: {
|
|
51074
|
+
trigger,
|
|
51075
|
+
detectionFps: sessionDetectionFps,
|
|
51076
|
+
cameraDetectionFps: config.detectionFps
|
|
51077
|
+
} });
|
|
50700
51078
|
const sessionConfig = {
|
|
50701
51079
|
deviceId,
|
|
50702
51080
|
...deviceKey ? { deviceKey } : {},
|
|
50703
51081
|
...inferenceDevices ? { inferenceDevices } : {},
|
|
50704
51082
|
motionCooldownMs: config.motionCooldownMs,
|
|
50705
51083
|
motionFps: config.motionFps,
|
|
50706
|
-
detectionFps:
|
|
51084
|
+
detectionFps: sessionDetectionFps,
|
|
50707
51085
|
motionStreamId: config.motionStreamId,
|
|
50708
51086
|
detectionStreamId: config.detectionStreamId,
|
|
50709
51087
|
motionSources: [],
|
|
@@ -52008,6 +52386,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
52008
52386
|
localNodeId: () => localNodeId,
|
|
52009
52387
|
deviceSettingsSchema: () => deps.deviceSettingsSchema(),
|
|
52010
52388
|
deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
|
|
52389
|
+
deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
|
|
52011
52390
|
deviceHasNativeObjectDetectionCap: (deviceId) => deps.deviceHasNativeObjectDetectionCap(deviceId),
|
|
52012
52391
|
setCameraPipelineForAgent: (input) => deps.setCameraPipelineForAgent(input),
|
|
52013
52392
|
emitCameraUpdated: (deviceId, config) => deps.emitCameraUpdated(deviceId, config),
|
|
@@ -52325,6 +52704,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
52325
52704
|
},
|
|
52326
52705
|
isCapActiveForDevice: (deviceId, capName) => deps.isCapActiveForDevice(deviceId, capName),
|
|
52327
52706
|
deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
|
|
52707
|
+
deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
|
|
52328
52708
|
deviceSettingsSchema: () => deps.deviceSettingsSchema()
|
|
52329
52709
|
});
|
|
52330
52710
|
await detectionWiring.hydrateFeaturesMirror().catch((err) => {
|
|
@@ -52360,255 +52740,6 @@ async function buildOrchestratorControllers(deps) {
|
|
|
52360
52740
|
};
|
|
52361
52741
|
}
|
|
52362
52742
|
//#endregion
|
|
52363
|
-
//#region src/viewer-ui-provider.ts
|
|
52364
|
-
/**
|
|
52365
|
-
* viewer-ui provider — the pipeline-orchestrator serves the CamStack viewer web
|
|
52366
|
-
* SPA (mirrors what the now-removed standalone addon-viewer-ui used to do).
|
|
52367
|
-
*
|
|
52368
|
-
* Why here: the orchestrator is a hub bootstrap addon (always installed + baked),
|
|
52369
|
-
* so folding the viewer serving into it avoids a second addon whose only job was
|
|
52370
|
-
* to hold static files. The viewer's Expo web export is COPIED into this addon's
|
|
52371
|
-
* `assets/viewer/` locally by the viewer repo's `scripts/copy-dist-to-orchestrator.js`
|
|
52372
|
-
* (run from a checkout that HAS the `camstack/` submodule + Expo toolchain — the
|
|
52373
|
-
* publish/image CI does not, which is exactly why we copy a pre-built dist rather
|
|
52374
|
-
* than build it here). vite emits only into `dist/`, so `assets/viewer` survives
|
|
52375
|
-
* the addon build; `assets` is in the package `files`, so it ships on
|
|
52376
|
-
* `camstack deploy`. The hub's `main.ts` resolves the `viewer-ui` singleton at
|
|
52377
|
-
* boot and mounts the SPA at `/viewer/camstack`.
|
|
52378
|
-
*
|
|
52379
|
-
* `index.js` runs from `dist/`, so the SPA root is one level up + `assets/viewer`.
|
|
52380
|
-
*/
|
|
52381
|
-
var __dirname$1 = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
52382
|
-
/** Absolute path to the staged viewer web SPA (`<addon-root>/assets/viewer`). */
|
|
52383
|
-
function resolveViewerDistDir() {
|
|
52384
|
-
return node_path.default.resolve(__dirname$1, "..", "assets", "viewer");
|
|
52385
|
-
}
|
|
52386
|
-
/** Version of the staged viewer, written by the copy script; 'unknown' if absent. */
|
|
52387
|
-
function readViewerVersion() {
|
|
52388
|
-
try {
|
|
52389
|
-
const raw = node_fs.default.readFileSync(node_path.default.join(resolveViewerDistDir(), ".viewer-version"), "utf-8").trim();
|
|
52390
|
-
if (raw) return raw;
|
|
52391
|
-
} catch {}
|
|
52392
|
-
return "unknown";
|
|
52393
|
-
}
|
|
52394
|
-
/** Build the viewer-ui provider. Serves whatever dist was staged into
|
|
52395
|
-
* `assets/viewer`; when nothing is staged the hub's mount cleanly 404s. */
|
|
52396
|
-
function createViewerUiProvider() {
|
|
52397
|
-
return {
|
|
52398
|
-
getStaticDir: async () => ({ staticDir: resolveViewerDistDir() }),
|
|
52399
|
-
getVersion: async () => ({ version: readViewerVersion() })
|
|
52400
|
-
};
|
|
52401
|
-
}
|
|
52402
|
-
//#endregion
|
|
52403
|
-
//#region src/disk-reconcile-fleet.ts
|
|
52404
|
-
async function reconcileFleetFromDisk(deps) {
|
|
52405
|
-
const deviceIds = await deps.listDeviceIds();
|
|
52406
|
-
const failed = [];
|
|
52407
|
-
let cameras = 0;
|
|
52408
|
-
let mediaDropped = 0;
|
|
52409
|
-
let tracks = 0;
|
|
52410
|
-
let events = 0;
|
|
52411
|
-
let completed = 0;
|
|
52412
|
-
for (const deviceId of deviceIds) {
|
|
52413
|
-
try {
|
|
52414
|
-
await deps.rescanRecordings(deviceId);
|
|
52415
|
-
const counts = await deps.reconcileAnalytics(deviceId);
|
|
52416
|
-
cameras += 1;
|
|
52417
|
-
mediaDropped += counts.mediaDropped;
|
|
52418
|
-
tracks += counts.tracks;
|
|
52419
|
-
events += counts.events;
|
|
52420
|
-
} catch {
|
|
52421
|
-
failed.push(deviceId);
|
|
52422
|
-
}
|
|
52423
|
-
completed += 1;
|
|
52424
|
-
deps.onProgress?.({
|
|
52425
|
-
deviceId,
|
|
52426
|
-
total: deviceIds.length,
|
|
52427
|
-
completed,
|
|
52428
|
-
failed: [...failed],
|
|
52429
|
-
mediaDropped,
|
|
52430
|
-
tracks,
|
|
52431
|
-
events
|
|
52432
|
-
});
|
|
52433
|
-
}
|
|
52434
|
-
return {
|
|
52435
|
-
cameras,
|
|
52436
|
-
failed,
|
|
52437
|
-
mediaDropped,
|
|
52438
|
-
tracks,
|
|
52439
|
-
events
|
|
52440
|
-
};
|
|
52441
|
-
}
|
|
52442
|
-
//#endregion
|
|
52443
|
-
//#region src/disk-reconcile-job.ts
|
|
52444
|
-
/**
|
|
52445
|
-
* In-memory disk-wins fleet job. The tRPC mutation starts this and returns
|
|
52446
|
-
* immediately; the walk runs in the addon process so a 60s UDS timeout cannot
|
|
52447
|
-
* abort it. Status is polled via getReconcileFromDiskStatus.
|
|
52448
|
-
*/
|
|
52449
|
-
function idleDiskReconcileJob() {
|
|
52450
|
-
return {
|
|
52451
|
-
state: "idle",
|
|
52452
|
-
total: 0,
|
|
52453
|
-
completed: 0,
|
|
52454
|
-
currentDeviceId: null,
|
|
52455
|
-
failed: [],
|
|
52456
|
-
mediaDropped: 0,
|
|
52457
|
-
tracks: 0,
|
|
52458
|
-
events: 0,
|
|
52459
|
-
startedAtMs: null,
|
|
52460
|
-
finishedAtMs: null,
|
|
52461
|
-
error: null
|
|
52462
|
-
};
|
|
52463
|
-
}
|
|
52464
|
-
function isTimeoutError(err) {
|
|
52465
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
52466
|
-
return /timed out/i.test(message);
|
|
52467
|
-
}
|
|
52468
|
-
async function withTimeoutRetry(run) {
|
|
52469
|
-
try {
|
|
52470
|
-
return await run();
|
|
52471
|
-
} catch (err) {
|
|
52472
|
-
if (!isTimeoutError(err)) throw err;
|
|
52473
|
-
return await run();
|
|
52474
|
-
}
|
|
52475
|
-
}
|
|
52476
|
-
function createDiskReconcileJobRunner(now = Date.now) {
|
|
52477
|
-
let job = idleDiskReconcileJob();
|
|
52478
|
-
let inFlight = null;
|
|
52479
|
-
const snapshot = () => job;
|
|
52480
|
-
const start = (deps) => {
|
|
52481
|
-
if (job.state === "running" && inFlight) return job;
|
|
52482
|
-
job = {
|
|
52483
|
-
...idleDiskReconcileJob(),
|
|
52484
|
-
state: "running",
|
|
52485
|
-
startedAtMs: now()
|
|
52486
|
-
};
|
|
52487
|
-
deps.log?.("pipeline disk reconcile started");
|
|
52488
|
-
inFlight = (async () => {
|
|
52489
|
-
try {
|
|
52490
|
-
const result = await reconcileFleetFromDisk({
|
|
52491
|
-
listDeviceIds: deps.listDeviceIds,
|
|
52492
|
-
rescanRecordings: (deviceId) => withTimeoutRetry(() => deps.rescanRecordings(deviceId)),
|
|
52493
|
-
reconcileAnalytics: (deviceId) => withTimeoutRetry(() => deps.reconcileAnalytics(deviceId)),
|
|
52494
|
-
onProgress: (update) => {
|
|
52495
|
-
job = {
|
|
52496
|
-
...job,
|
|
52497
|
-
total: update.total,
|
|
52498
|
-
completed: update.completed,
|
|
52499
|
-
currentDeviceId: update.deviceId,
|
|
52500
|
-
failed: update.failed,
|
|
52501
|
-
mediaDropped: update.mediaDropped,
|
|
52502
|
-
tracks: update.tracks,
|
|
52503
|
-
events: update.events
|
|
52504
|
-
};
|
|
52505
|
-
deps.onProgress?.(update);
|
|
52506
|
-
deps.log?.("pipeline disk reconcile camera", {
|
|
52507
|
-
deviceId: update.deviceId,
|
|
52508
|
-
completed: update.completed,
|
|
52509
|
-
total: update.total,
|
|
52510
|
-
failed: update.failed.length,
|
|
52511
|
-
mediaDropped: update.mediaDropped,
|
|
52512
|
-
tracks: update.tracks,
|
|
52513
|
-
events: update.events
|
|
52514
|
-
});
|
|
52515
|
-
}
|
|
52516
|
-
});
|
|
52517
|
-
job = {
|
|
52518
|
-
...job,
|
|
52519
|
-
state: "done",
|
|
52520
|
-
total: result.cameras + result.failed.length,
|
|
52521
|
-
completed: result.cameras + result.failed.length,
|
|
52522
|
-
currentDeviceId: null,
|
|
52523
|
-
failed: result.failed,
|
|
52524
|
-
mediaDropped: result.mediaDropped,
|
|
52525
|
-
tracks: result.tracks,
|
|
52526
|
-
events: result.events,
|
|
52527
|
-
finishedAtMs: now(),
|
|
52528
|
-
error: null
|
|
52529
|
-
};
|
|
52530
|
-
deps.log?.("pipeline disk reconcile", {
|
|
52531
|
-
cameras: result.cameras,
|
|
52532
|
-
failed: result.failed,
|
|
52533
|
-
mediaDropped: result.mediaDropped,
|
|
52534
|
-
tracks: result.tracks,
|
|
52535
|
-
events: result.events
|
|
52536
|
-
});
|
|
52537
|
-
} catch (err) {
|
|
52538
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
52539
|
-
job = {
|
|
52540
|
-
...job,
|
|
52541
|
-
state: "error",
|
|
52542
|
-
currentDeviceId: null,
|
|
52543
|
-
finishedAtMs: now(),
|
|
52544
|
-
error
|
|
52545
|
-
};
|
|
52546
|
-
deps.log?.("pipeline disk reconcile failed", { error });
|
|
52547
|
-
} finally {
|
|
52548
|
-
inFlight = null;
|
|
52549
|
-
}
|
|
52550
|
-
})();
|
|
52551
|
-
return job;
|
|
52552
|
-
};
|
|
52553
|
-
return {
|
|
52554
|
-
snapshot,
|
|
52555
|
-
start
|
|
52556
|
-
};
|
|
52557
|
-
}
|
|
52558
|
-
//#endregion
|
|
52559
|
-
//#region src/widget-catalog.ts
|
|
52560
|
-
var pipelineOrchestratorWidgets = [{
|
|
52561
|
-
tab: "device-tab",
|
|
52562
|
-
label: "Pipeline Quick Stats",
|
|
52563
|
-
preAuth: false,
|
|
52564
|
-
kind: "remote",
|
|
52565
|
-
remote: {
|
|
52566
|
-
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
52567
|
-
exposedModule: "./widgets",
|
|
52568
|
-
componentKey: "pipeline-quick-stats"
|
|
52569
|
-
},
|
|
52570
|
-
stableId: "pipeline-quick-stats",
|
|
52571
|
-
description: "Phase / Detection FPS / Inference / Active Tracks tile row.",
|
|
52572
|
-
icon: "activity",
|
|
52573
|
-
bundle: "remoteEntry.js",
|
|
52574
|
-
hosts: ["device-tab", "dashboard"],
|
|
52575
|
-
requires: {
|
|
52576
|
-
deviceContext: true,
|
|
52577
|
-
integrationContext: false
|
|
52578
|
-
},
|
|
52579
|
-
defaultSize: "md",
|
|
52580
|
-
allowedSizes: [
|
|
52581
|
-
"sm",
|
|
52582
|
-
"md",
|
|
52583
|
-
"lg"
|
|
52584
|
-
],
|
|
52585
|
-
defaultColumns: 6,
|
|
52586
|
-
defaultRows: 1
|
|
52587
|
-
}, {
|
|
52588
|
-
tab: "device-tab",
|
|
52589
|
-
label: "Zone Editor",
|
|
52590
|
-
preAuth: false,
|
|
52591
|
-
kind: "remote",
|
|
52592
|
-
remote: {
|
|
52593
|
-
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
52594
|
-
exposedModule: "./widgets",
|
|
52595
|
-
componentKey: "zone-editor"
|
|
52596
|
-
},
|
|
52597
|
-
stableId: "zone-editor",
|
|
52598
|
-
description: "Polygon / tripwire CRUD + per-stage rule editor.",
|
|
52599
|
-
icon: "shapes",
|
|
52600
|
-
bundle: "remoteEntry.js",
|
|
52601
|
-
hosts: ["device-tab"],
|
|
52602
|
-
requires: {
|
|
52603
|
-
deviceContext: true,
|
|
52604
|
-
integrationContext: false
|
|
52605
|
-
},
|
|
52606
|
-
defaultSize: "xl",
|
|
52607
|
-
allowedSizes: ["lg", "xl"],
|
|
52608
|
-
defaultColumns: 12,
|
|
52609
|
-
defaultRows: 4
|
|
52610
|
-
}];
|
|
52611
|
-
//#endregion
|
|
52612
52743
|
//#region src/settings-ui-schemas.ts
|
|
52613
52744
|
/** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
|
|
52614
52745
|
function buildGlobalSettingsSections(options) {
|
|
@@ -52973,6 +53104,22 @@ function buildDeviceSettingsSections(nodeOptions) {
|
|
|
52973
53104
|
field: "detectionMode",
|
|
52974
53105
|
notEquals: "disabled"
|
|
52975
53106
|
}
|
|
53107
|
+
},
|
|
53108
|
+
{
|
|
53109
|
+
key: "activityDetectionFps",
|
|
53110
|
+
type: "slider",
|
|
53111
|
+
label: "Detection FPS while the device is working",
|
|
53112
|
+
min: 1,
|
|
53113
|
+
max: 10,
|
|
53114
|
+
step: 1,
|
|
53115
|
+
default: 1,
|
|
53116
|
+
showValue: true,
|
|
53117
|
+
unit: "fps",
|
|
53118
|
+
description: "A device-activity session lasts as long as the job does — a cleaning run is tens of minutes. This caps the rate for that session only; a session opened by motion keeps the rate above.",
|
|
53119
|
+
showWhen: {
|
|
53120
|
+
field: "motionSources",
|
|
53121
|
+
includes: "device-activity"
|
|
53122
|
+
}
|
|
52976
53123
|
}
|
|
52977
53124
|
]
|
|
52978
53125
|
},
|
|
@@ -53062,6 +53209,99 @@ function deriveRuntimeSettings(config) {
|
|
|
53062
53209
|
};
|
|
53063
53210
|
}
|
|
53064
53211
|
//#endregion
|
|
53212
|
+
//#region src/viewer-ui-provider.ts
|
|
53213
|
+
/**
|
|
53214
|
+
* viewer-ui provider — the pipeline-orchestrator serves the CamStack viewer web
|
|
53215
|
+
* SPA (mirrors what the now-removed standalone addon-viewer-ui used to do).
|
|
53216
|
+
*
|
|
53217
|
+
* Why here: the orchestrator is a hub bootstrap addon (always installed + baked),
|
|
53218
|
+
* so folding the viewer serving into it avoids a second addon whose only job was
|
|
53219
|
+
* to hold static files. The viewer's Expo web export is COPIED into this addon's
|
|
53220
|
+
* `assets/viewer/` locally by the viewer repo's `scripts/copy-dist-to-orchestrator.js`
|
|
53221
|
+
* (run from a checkout that HAS the `camstack/` submodule + Expo toolchain — the
|
|
53222
|
+
* publish/image CI does not, which is exactly why we copy a pre-built dist rather
|
|
53223
|
+
* than build it here). vite emits only into `dist/`, so `assets/viewer` survives
|
|
53224
|
+
* the addon build; `assets` is in the package `files`, so it ships on
|
|
53225
|
+
* `camstack deploy`. The hub's `main.ts` resolves the `viewer-ui` singleton at
|
|
53226
|
+
* boot and mounts the SPA at `/viewer/camstack`.
|
|
53227
|
+
*
|
|
53228
|
+
* `index.js` runs from `dist/`, so the SPA root is one level up + `assets/viewer`.
|
|
53229
|
+
*/
|
|
53230
|
+
var __dirname$1 = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
53231
|
+
/** Absolute path to the staged viewer web SPA (`<addon-root>/assets/viewer`). */
|
|
53232
|
+
function resolveViewerDistDir() {
|
|
53233
|
+
return node_path.default.resolve(__dirname$1, "..", "assets", "viewer");
|
|
53234
|
+
}
|
|
53235
|
+
/** Version of the staged viewer, written by the copy script; 'unknown' if absent. */
|
|
53236
|
+
function readViewerVersion() {
|
|
53237
|
+
try {
|
|
53238
|
+
const raw = node_fs.default.readFileSync(node_path.default.join(resolveViewerDistDir(), ".viewer-version"), "utf-8").trim();
|
|
53239
|
+
if (raw) return raw;
|
|
53240
|
+
} catch {}
|
|
53241
|
+
return "unknown";
|
|
53242
|
+
}
|
|
53243
|
+
/** Build the viewer-ui provider. Serves whatever dist was staged into
|
|
53244
|
+
* `assets/viewer`; when nothing is staged the hub's mount cleanly 404s. */
|
|
53245
|
+
function createViewerUiProvider() {
|
|
53246
|
+
return {
|
|
53247
|
+
getStaticDir: async () => ({ staticDir: resolveViewerDistDir() }),
|
|
53248
|
+
getVersion: async () => ({ version: readViewerVersion() })
|
|
53249
|
+
};
|
|
53250
|
+
}
|
|
53251
|
+
//#endregion
|
|
53252
|
+
//#region src/widget-catalog.ts
|
|
53253
|
+
var pipelineOrchestratorWidgets = [{
|
|
53254
|
+
tab: "device-tab",
|
|
53255
|
+
label: "Pipeline Quick Stats",
|
|
53256
|
+
preAuth: false,
|
|
53257
|
+
kind: "remote",
|
|
53258
|
+
remote: {
|
|
53259
|
+
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
53260
|
+
exposedModule: "./widgets",
|
|
53261
|
+
componentKey: "pipeline-quick-stats"
|
|
53262
|
+
},
|
|
53263
|
+
stableId: "pipeline-quick-stats",
|
|
53264
|
+
description: "Phase / Detection FPS / Inference / Active Tracks tile row.",
|
|
53265
|
+
icon: "activity",
|
|
53266
|
+
bundle: "remoteEntry.js",
|
|
53267
|
+
hosts: ["device-tab", "dashboard"],
|
|
53268
|
+
requires: {
|
|
53269
|
+
deviceContext: true,
|
|
53270
|
+
integrationContext: false
|
|
53271
|
+
},
|
|
53272
|
+
defaultSize: "md",
|
|
53273
|
+
allowedSizes: [
|
|
53274
|
+
"sm",
|
|
53275
|
+
"md",
|
|
53276
|
+
"lg"
|
|
53277
|
+
],
|
|
53278
|
+
defaultColumns: 6,
|
|
53279
|
+
defaultRows: 1
|
|
53280
|
+
}, {
|
|
53281
|
+
tab: "device-tab",
|
|
53282
|
+
label: "Zone Editor",
|
|
53283
|
+
preAuth: false,
|
|
53284
|
+
kind: "remote",
|
|
53285
|
+
remote: {
|
|
53286
|
+
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
53287
|
+
exposedModule: "./widgets",
|
|
53288
|
+
componentKey: "zone-editor"
|
|
53289
|
+
},
|
|
53290
|
+
stableId: "zone-editor",
|
|
53291
|
+
description: "Polygon / tripwire CRUD + per-stage rule editor.",
|
|
53292
|
+
icon: "shapes",
|
|
53293
|
+
bundle: "remoteEntry.js",
|
|
53294
|
+
hosts: ["device-tab"],
|
|
53295
|
+
requires: {
|
|
53296
|
+
deviceContext: true,
|
|
53297
|
+
integrationContext: false
|
|
53298
|
+
},
|
|
53299
|
+
defaultSize: "xl",
|
|
53300
|
+
allowedSizes: ["lg", "xl"],
|
|
53301
|
+
defaultColumns: 12,
|
|
53302
|
+
defaultRows: 4
|
|
53303
|
+
}];
|
|
53304
|
+
//#endregion
|
|
53065
53305
|
//#region src/index.ts
|
|
53066
53306
|
/**
|
|
53067
53307
|
* addon-pipeline-orchestrator — hub-side camera-to-agent load balancer.
|
|
@@ -53376,6 +53616,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
53376
53616
|
isCapActiveForDevice: (deviceId, capName) => this.isCapActiveForDevice(deviceId, capName),
|
|
53377
53617
|
isAudioAnalysisActive: (deviceId) => this.isAudioAnalysisActive(deviceId),
|
|
53378
53618
|
deviceHasOnboardMotionCap: (deviceId) => this.deviceHasOnboardMotionCap(deviceId),
|
|
53619
|
+
deviceHasActivitySignalCap: (deviceId) => this.deviceHasActivitySignalCap(deviceId),
|
|
53379
53620
|
deviceHasNativeObjectDetectionCap: (deviceId) => this.deviceHasNativeObjectDetectionCap(deviceId),
|
|
53380
53621
|
handleDeviceRegistered: (deviceId) => this.handleDeviceRegistered(deviceId),
|
|
53381
53622
|
handleDeviceUnregistered: (deviceId) => this.handleDeviceUnregistered(deviceId),
|
|
@@ -54562,6 +54803,26 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
54562
54803
|
* we err toward the analyzer (never lose detection coverage when the
|
|
54563
54804
|
* binding lookup hiccups).
|
|
54564
54805
|
*/
|
|
54806
|
+
/**
|
|
54807
|
+
* True when the device's driver registered `recording-signal` — the cap a
|
|
54808
|
+
* device uses to say, itself, that it is working (a robot vacuum cleaning).
|
|
54809
|
+
* Drives the `device-activity` entry in the `motionSources` DEFAULT (D392),
|
|
54810
|
+
* so the source arrives on exactly the devices that can raise it and on no
|
|
54811
|
+
* other camera in the fleet.
|
|
54812
|
+
*
|
|
54813
|
+
* Same failure discipline as `deviceHasOnboardMotionCap`: a binding lookup
|
|
54814
|
+
* that throws answers `false`, which loses the DEFAULT and never the
|
|
54815
|
+
* operator's explicit choice (this is asked only when they pinned nothing).
|
|
54816
|
+
*/
|
|
54817
|
+
async deviceHasActivitySignalCap(deviceId) {
|
|
54818
|
+
const api = this.api;
|
|
54819
|
+
if (!api) return false;
|
|
54820
|
+
try {
|
|
54821
|
+
return (await api.deviceManager.getBindings.query({ deviceId })).entries.some((e) => e.kind === "native" && e.capName === "recording-signal");
|
|
54822
|
+
} catch {
|
|
54823
|
+
return false;
|
|
54824
|
+
}
|
|
54825
|
+
}
|
|
54565
54826
|
async deviceHasOnboardMotionCap(deviceId) {
|
|
54566
54827
|
const api = this.api;
|
|
54567
54828
|
if (!api) return false;
|