@camstack/addon-pipeline-orchestrator 1.2.166 → 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/_stub.js +2 -2
- package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-C4tQ6wzf.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-C5V3S9Pp.mjs} +3 -3
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Ckk-7c_N.mjs +26 -0
- package/dist/{_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DSO_ZOH-.mjs → _virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Druu1Kh6.mjs} +1 -1
- package/dist/{hostInit-qzzyEAID.mjs → hostInit-cWPH1Lu7.mjs} +3 -3
- package/dist/index.js +1822 -1426
- package/dist/index.mjs +1822 -1426
- package/dist/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BwoEqu8i.mjs +0 -26
package/dist/index.js
CHANGED
|
@@ -30,7 +30,7 @@ node_fs = __toESM(node_fs);
|
|
|
30
30
|
let node_path = require("node:path");
|
|
31
31
|
node_path = __toESM(node_path);
|
|
32
32
|
let node_url = require("node:url");
|
|
33
|
-
//#region ../types/dist/event-category-
|
|
33
|
+
//#region ../types/dist/event-category-CnLqLOKs.mjs
|
|
34
34
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
35
35
|
EventCategory["SystemBoot"] = "system.boot";
|
|
36
36
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -225,6 +225,19 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
225
225
|
EventCategory["ProcessCrashed"] = "process.crashed";
|
|
226
226
|
EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
|
|
227
227
|
EventCategory["ProcessRestarted"] = "process.restarted";
|
|
228
|
+
/**
|
|
229
|
+
* The SET of storage locations changed — one was created, edited, enabled,
|
|
230
|
+
* disabled or deleted through `storage.upsertLocation` / `deleteLocation`.
|
|
231
|
+
*
|
|
232
|
+
* Telemetry, not a transaction (D8/D11): every consumer that re-resolves on
|
|
233
|
+
* it must also converge on its own periodic path, because a dropped event
|
|
234
|
+
* must not leave a node writing to yesterday's disk set forever. It exists
|
|
235
|
+
* because there was NO signal at all — an operator who added a second
|
|
236
|
+
* recordings disk in the admin UI got nothing, and the recorder kept its
|
|
237
|
+
* resolved locations until something else happened to re-resolve them
|
|
238
|
+
* (D387). Payload `StorageLocationsChangedPayload`.
|
|
239
|
+
*/
|
|
240
|
+
EventCategory["StorageLocationsChanged"] = "storage.locations-changed";
|
|
228
241
|
EventCategory["RecordingStarted"] = "recording.started";
|
|
229
242
|
EventCategory["RecordingStopped"] = "recording.stopped";
|
|
230
243
|
EventCategory["RecordingError"] = "recording.error";
|
|
@@ -9412,6 +9425,21 @@ var StorageCleanupJobSchema = object({
|
|
|
9412
9425
|
});
|
|
9413
9426
|
var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
|
|
9414
9427
|
/**
|
|
9428
|
+
* The one typed state of a storage location. Authoritative Zod schema — the TS
|
|
9429
|
+
* alias below is `z.infer<>` of it, never a second spelling.
|
|
9430
|
+
*/
|
|
9431
|
+
var StorageLocationModeSchema = _enum([
|
|
9432
|
+
"active",
|
|
9433
|
+
"readonly",
|
|
9434
|
+
"drain",
|
|
9435
|
+
"disabled"
|
|
9436
|
+
]);
|
|
9437
|
+
_enum([
|
|
9438
|
+
"normal",
|
|
9439
|
+
"never",
|
|
9440
|
+
"drain"
|
|
9441
|
+
]);
|
|
9442
|
+
/**
|
|
9415
9443
|
* `StorageLocationType` — an addon-declared id that identifies the *kind* of
|
|
9416
9444
|
* storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
|
|
9417
9445
|
* so the persisted record schema and the consumer-facing cap can both consume it
|
|
@@ -9477,6 +9505,21 @@ var StorageLocationSchema = object({
|
|
|
9477
9505
|
* stops existing rather than being re-derived on every read.
|
|
9478
9506
|
*/
|
|
9479
9507
|
enabled: boolean().optional(),
|
|
9508
|
+
/**
|
|
9509
|
+
* THE state of this location (D385), and the only authority on what may be
|
|
9510
|
+
* written, read or evicted here. Interpreted in exactly one place —
|
|
9511
|
+
* `storage-location-mode.ts` — which also folds the legacy
|
|
9512
|
+
* `enabled` / `config.readOnly` pair into a mode so an old row is never
|
|
9513
|
+
* ambiguous.
|
|
9514
|
+
*
|
|
9515
|
+
* OPTIONAL only for the wire and for rows written before D385: absence is
|
|
9516
|
+
* resolved by `resolveLocationMode`, and the orchestrator stamps every
|
|
9517
|
+
* unstamped row ONCE at hydrate so absence stops existing rather than being
|
|
9518
|
+
* re-derived on every read. `enabled` survives one release as a DERIVED
|
|
9519
|
+
* mirror (`mode === 'active'`); `withLocationMode` is the only writer of
|
|
9520
|
+
* either, so the two cannot disagree.
|
|
9521
|
+
*/
|
|
9522
|
+
mode: StorageLocationModeSchema.optional(),
|
|
9480
9523
|
/** COMPUTED at read time by the orchestrator (statfs of the backing volume
|
|
9481
9524
|
* for node-local locations it can reach) — never persisted, absent when the
|
|
9482
9525
|
* volume is remote/unreachable. The single capacity truth every UI reads. */
|
|
@@ -9484,11 +9527,46 @@ var StorageLocationSchema = object({
|
|
|
9484
9527
|
totalBytes: number(),
|
|
9485
9528
|
availableBytes: number()
|
|
9486
9529
|
}).nullable().optional(),
|
|
9530
|
+
/**
|
|
9531
|
+
* How much of that volume CamStack ITSELF holds on this location (D388) —
|
|
9532
|
+
* COMPUTED at read time from the `storage-occupancy` providers' own figures,
|
|
9533
|
+
* never persisted, never a filesystem walk.
|
|
9534
|
+
*
|
|
9535
|
+
* **ABSENT MEANS UNKNOWN, never zero.** No provider has reported for this
|
|
9536
|
+
* location yet — nobody stores here, the owning addon is down, or the first
|
|
9537
|
+
* refresh has not completed. A UI must omit the segment rather than draw it
|
|
9538
|
+
* at zero, which would claim we occupy nothing (D315). It is an OBJECT and
|
|
9539
|
+
* not a bare number precisely so that a `?? 0` on the consuming side has to
|
|
9540
|
+
* be spelled out loud instead of appearing by accident.
|
|
9541
|
+
*
|
|
9542
|
+
* `measuredAtMs` is the OLDEST contributing measurement, so it is honest
|
|
9543
|
+
* about the whole figure rather than about its freshest part.
|
|
9544
|
+
*/
|
|
9545
|
+
owned: object({
|
|
9546
|
+
bytes: number().int().nonnegative(),
|
|
9547
|
+
measuredAtMs: number().int().nonnegative()
|
|
9548
|
+
}).optional(),
|
|
9487
9549
|
createdAt: number(),
|
|
9488
9550
|
updatedAt: number()
|
|
9489
9551
|
});
|
|
9490
9552
|
object({ isDefault: boolean().optional() });
|
|
9491
9553
|
/**
|
|
9554
|
+
* How far a `drain` has got (D386) — the read a UI renders, and nothing more.
|
|
9555
|
+
*
|
|
9556
|
+
* `estimatedEmptyAtMs` is derived from the growth the ratchet has actually
|
|
9557
|
+
* OBSERVED and is `null` when it has observed none. Never a fabricated date: a
|
|
9558
|
+
* drain with no observed growth has no honest ETA, and inventing one is how an
|
|
9559
|
+
* operator learns not to believe the screen.
|
|
9560
|
+
*/
|
|
9561
|
+
var StorageDrainProgressSchema = object({
|
|
9562
|
+
locationId: string(),
|
|
9563
|
+
startedAtMs: number(),
|
|
9564
|
+
startBytes: number(),
|
|
9565
|
+
bytesRemaining: number(),
|
|
9566
|
+
drained: boolean(),
|
|
9567
|
+
estimatedEmptyAtMs: number().nullable()
|
|
9568
|
+
});
|
|
9569
|
+
/**
|
|
9492
9570
|
* Reference accepted by consumer-facing `api.storage.*` calls.
|
|
9493
9571
|
* Either:
|
|
9494
9572
|
* - a `StorageLocationType` (e.g. `'backups'`) → the sole location of that type
|
|
@@ -21232,8 +21310,25 @@ var occupancyRecheckFramesField = {
|
|
|
21232
21310
|
* (analyzer attaches detected `regions[]`; onboard does not — the
|
|
21233
21311
|
* camera typically only reports a binary signal plus an optional
|
|
21234
21312
|
* channel/AI class which lives in dedicated event channels).
|
|
21235
|
-
|
|
21236
|
-
|
|
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
|
+
]);
|
|
21237
21332
|
/**
|
|
21238
21333
|
* List of motion sources active on a camera. Empty array is valid:
|
|
21239
21334
|
* "no source" — happens for battery cams without firmware motion when
|
|
@@ -21497,13 +21592,20 @@ var RunnerCameraDeviceUIFields = [
|
|
|
21497
21592
|
type: "multiselect",
|
|
21498
21593
|
label: "Motion Sources",
|
|
21499
21594
|
default: ["analyzer"],
|
|
21500
|
-
options: [
|
|
21501
|
-
|
|
21502
|
-
|
|
21503
|
-
|
|
21504
|
-
|
|
21505
|
-
|
|
21506
|
-
|
|
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
|
+
]
|
|
21507
21609
|
},
|
|
21508
21610
|
{
|
|
21509
21611
|
key: "motionFps",
|
|
@@ -23240,7 +23342,7 @@ method(object({
|
|
|
23240
23342
|
}), _void(), {
|
|
23241
23343
|
kind: "mutation",
|
|
23242
23344
|
auth: "admin"
|
|
23243
|
-
}), method(object({ id: string() }), object({
|
|
23345
|
+
}), method(_void(), array(StorageDrainProgressSchema).readonly()), method(object({ id: string() }), object({
|
|
23244
23346
|
ok: boolean(),
|
|
23245
23347
|
error: string().optional()
|
|
23246
23348
|
}), { auth: "admin" }), method(_void(), array(ProviderListEntrySchema).readonly()), method(object({
|
|
@@ -23310,6 +23412,51 @@ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin"
|
|
|
23310
23412
|
kind: "mutation",
|
|
23311
23413
|
auth: "admin"
|
|
23312
23414
|
}), method(object({}), array(StorageMigrationJobSchema).readonly(), { auth: "admin" });
|
|
23415
|
+
/**
|
|
23416
|
+
* `storage-occupancy` — how many bytes an addon actually HOLDS on a storage
|
|
23417
|
+
* location (D388).
|
|
23418
|
+
*
|
|
23419
|
+
* ## Why this is not `storage-evictable`
|
|
23420
|
+
*
|
|
23421
|
+
* `storage-evictable.getEvictableUsage` looks like the same question and is
|
|
23422
|
+
* not, in two ways that both matter and both bite hardest on the locations an
|
|
23423
|
+
* operator most wants a figure for:
|
|
23424
|
+
*
|
|
23425
|
+
* - it reports the whole eviction DOMAIN, not the location. `recordings:default`
|
|
23426
|
+
* and `recordingsLow:default` deliberately share one root and evict as one
|
|
23427
|
+
* oldest-first pool, so both answer with the SAME combined total. As an
|
|
23428
|
+
* occupancy figure that double-counts the disk.
|
|
23429
|
+
* - it reports ZERO for a location whose eviction policy is `never` (D385) —
|
|
23430
|
+
* a `readonly` or `disabled` disk. Those are exactly the disks an operator
|
|
23431
|
+
* is retiring and staring at.
|
|
23432
|
+
*
|
|
23433
|
+
* So this is its own contract with its own quantity, and the quantity is
|
|
23434
|
+
* OCCUPIED: every byte the addon holds on that location, whether or not it
|
|
23435
|
+
* would ever be willing to delete it. A provider that can only answer
|
|
23436
|
+
* "evictable" must not register here — a number that silently means different
|
|
23437
|
+
* things per class is worse than no number.
|
|
23438
|
+
*
|
|
23439
|
+
* ## Absence is an answer
|
|
23440
|
+
*
|
|
23441
|
+
* A location nobody reports for is UNKNOWN, never zero (D315). The orchestrator
|
|
23442
|
+
* stamps `StorageLocation.owned` only for locations it has a report for, and
|
|
23443
|
+
* the field is an OBJECT rather than a bare number so that a `?? 0` on the
|
|
23444
|
+
* consuming side has to be written out loud instead of appearing by accident.
|
|
23445
|
+
*
|
|
23446
|
+
* `internal: true` — consumed by the orchestrator's `listLocations` stamp, never
|
|
23447
|
+
* a public client surface. Clients read the stamped `StorageLocation.owned`.
|
|
23448
|
+
*/
|
|
23449
|
+
/** One provider's occupancy answer for one location. */
|
|
23450
|
+
var StorageOccupancyReportSchema = object({
|
|
23451
|
+
locationId: string(),
|
|
23452
|
+
/** Bytes this provider holds on THAT location — not its eviction domain, and
|
|
23453
|
+
* not net of what it is willing to delete. */
|
|
23454
|
+
ownedBytes: number().int().nonnegative(),
|
|
23455
|
+
/** When the provider last actually measured this. The orchestrator carries it
|
|
23456
|
+
* through so a UI can say how old the figure is instead of implying "now". */
|
|
23457
|
+
measuredAtMs: number().int().nonnegative()
|
|
23458
|
+
});
|
|
23459
|
+
method(object({ locationIds: array(string()).readonly() }), array(StorageOccupancyReportSchema).readonly(), { auth: "admin" });
|
|
23313
23460
|
var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
23314
23461
|
providerId: string().min(1),
|
|
23315
23462
|
displayName: string().min(1),
|
|
@@ -24945,39 +25092,6 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, meth
|
|
|
24945
25092
|
deviceId: number(),
|
|
24946
25093
|
status: BatteryStatusSchema
|
|
24947
25094
|
});
|
|
24948
|
-
/**
|
|
24949
|
-
* Network-link snapshot. Same shape for every provider (a Reolink wifi
|
|
24950
|
-
* camera, a Home Assistant device with a signal-strength sensor, a Tapo
|
|
24951
|
-
* plug): one slice under `device.runtimeState['network-link']`, one badge,
|
|
24952
|
-
* one Home Assistant projection.
|
|
24953
|
-
*/
|
|
24954
|
-
var NetworkLinkStatusSchema = object({
|
|
24955
|
-
/** The link the device is on. `'unknown'` = not read yet, not "no link". */
|
|
24956
|
-
type: _enum([
|
|
24957
|
-
"wifi",
|
|
24958
|
-
"ethernet",
|
|
24959
|
-
"cellular",
|
|
24960
|
-
"unknown"
|
|
24961
|
-
]),
|
|
24962
|
-
/**
|
|
24963
|
-
* Link quality, 0..100 inclusive, normalised by the provider from whatever
|
|
24964
|
-
* the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
|
|
24965
|
-
* KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
|
|
24966
|
-
* one whose reading has not landed must not be drawn at 0 %. Consumers
|
|
24967
|
-
* SKIP a null rather than coerce it.
|
|
24968
|
-
*/
|
|
24969
|
-
signalPercent: number().min(0).max(100).nullable(),
|
|
24970
|
-
/** Raw received signal strength in dBm, when the firmware reports one. */
|
|
24971
|
-
rssiDbm: number().optional(),
|
|
24972
|
-
/** Network name of a wireless link, when the firmware reports it. */
|
|
24973
|
-
ssid: string().optional(),
|
|
24974
|
-
/** Ms epoch of the last observation. Lets consumers reason about freshness. */
|
|
24975
|
-
lastUpdated: number()
|
|
24976
|
-
});
|
|
24977
|
-
DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
|
|
24978
|
-
deviceId: number(),
|
|
24979
|
-
status: NetworkLinkStatusSchema
|
|
24980
|
-
});
|
|
24981
25095
|
object({
|
|
24982
25096
|
on: boolean(),
|
|
24983
25097
|
/** Ms epoch of the last transition. 0 if never observed. */
|
|
@@ -27331,6 +27445,236 @@ DeviceType.Camera, method(object({
|
|
|
27331
27445
|
detection: NativeDetectionSchema
|
|
27332
27446
|
});
|
|
27333
27447
|
/**
|
|
27448
|
+
* `navigation` — a device-scoped capability that natively expresses the FULL
|
|
27449
|
+
* navigation / action surface of a robot that DRIVES ITSELF and carries an
|
|
27450
|
+
* on-board camera (the Dreame robot-vacuum camera is the first provider).
|
|
27451
|
+
*
|
|
27452
|
+
* Why a NEW cap rather than overloading `ptz`:
|
|
27453
|
+
* - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
|
|
27454
|
+
* robot vacuum has no gimbal — the whole chassis drives, turns and spins.
|
|
27455
|
+
* The two are different physical models: PTZ is absolute-position + presets,
|
|
27456
|
+
* navigation is momentary drive nudges + discrete robot ACTIONS
|
|
27457
|
+
* (dock / spot-clean / follow-pet / go-to-point / …).
|
|
27458
|
+
* - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
|
|
27459
|
+
* the reverse:
|
|
27460
|
+
* 1. a native CamStack navigation panel (data-driven from `listActions`
|
|
27461
|
+
* / `getOptions`), and
|
|
27462
|
+
* 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
|
|
27463
|
+
* robot camera shows up in the existing PTZ control path without every
|
|
27464
|
+
* PTZ provider learning about robots. The mapping lives in the adapter,
|
|
27465
|
+
* not here (see the addon design note):
|
|
27466
|
+
* ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
|
|
27467
|
+
* ptz.stop() → navigation.stop()
|
|
27468
|
+
* ptz.goHome() → navigation.runAction('goHome')
|
|
27469
|
+
* ptz.getPresets() → navigation.listActions() (id→preset)
|
|
27470
|
+
* ptz.goToPreset(id) → navigation.runAction(id)
|
|
27471
|
+
*
|
|
27472
|
+
* ## Continuous drive
|
|
27473
|
+
*
|
|
27474
|
+
* `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
|
|
27475
|
+
* sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
|
|
27476
|
+
* one `stop()` on release — exactly like the robot app's remote-drive joystick.
|
|
27477
|
+
* The provider forwards EACH `move` to one drive write; it must NOT debounce or
|
|
27478
|
+
* coalesce them. The UI owns the cadence.
|
|
27479
|
+
*
|
|
27480
|
+
* ## The action dictionary
|
|
27481
|
+
*
|
|
27482
|
+
* The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
|
|
27483
|
+
* are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
|
|
27484
|
+
* carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
|
|
27485
|
+
* native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
|
|
27486
|
+
* vendor-specific list. `kind: 'action'` entries are triggered with
|
|
27487
|
+
* `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
|
|
27488
|
+
* (the entry carries the `soundId` to pass). The general primitives — `move`,
|
|
27489
|
+
* `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
|
|
27490
|
+
*
|
|
27491
|
+
* NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
|
|
27492
|
+
* `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
|
|
27493
|
+
* that the currently-published `@apocaliss92/nodedreame` already exposes on
|
|
27494
|
+
* every device handle. A future nodedreame publish adds a typed
|
|
27495
|
+
* `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
|
|
27496
|
+
* provider can then swap the raw calls for the typed methods with no change to
|
|
27497
|
+
* THIS contract.
|
|
27498
|
+
*/
|
|
27499
|
+
/**
|
|
27500
|
+
* A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
|
|
27501
|
+
* keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
|
|
27502
|
+
* halts it.
|
|
27503
|
+
*
|
|
27504
|
+
* - `pan` — turn: negative = left, positive = right, 0 = straight.
|
|
27505
|
+
* - `tilt` — throttle: positive = forward, negative = spin / turn-around.
|
|
27506
|
+
* - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
|
|
27507
|
+
* vector by it (drivers without proportional drive ignore it).
|
|
27508
|
+
*
|
|
27509
|
+
* `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
|
|
27510
|
+
* axis alone; an all-undefined nudge is a no-op.
|
|
27511
|
+
*/
|
|
27512
|
+
var NavigationMoveCommandSchema = object({
|
|
27513
|
+
pan: number().min(-1).max(1).optional(),
|
|
27514
|
+
tilt: number().min(-1).max(1).optional(),
|
|
27515
|
+
speed: number().min(0).max(1).optional()
|
|
27516
|
+
});
|
|
27517
|
+
/**
|
|
27518
|
+
* The enumerated discrete actions a navigation-capable robot can perform via
|
|
27519
|
+
* `runAction`. This is the CLOSED vocabulary; a given device advertises the
|
|
27520
|
+
* subset it supports through `listActions`. Sounds are NOT here — they go through
|
|
27521
|
+
* `playSound` (see the `sound` dictionary entries).
|
|
27522
|
+
*/
|
|
27523
|
+
var NavigationActionIdSchema = _enum([
|
|
27524
|
+
"goHome",
|
|
27525
|
+
"locate",
|
|
27526
|
+
"spotClean",
|
|
27527
|
+
"findPet",
|
|
27528
|
+
"personFollow",
|
|
27529
|
+
"stop",
|
|
27530
|
+
"startClean",
|
|
27531
|
+
"pauseClean",
|
|
27532
|
+
"dockWash",
|
|
27533
|
+
"autoEmpty",
|
|
27534
|
+
"flashOn",
|
|
27535
|
+
"flashOff"
|
|
27536
|
+
]);
|
|
27537
|
+
/** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
|
|
27538
|
+
var NavigationEntryKindSchema = _enum(["action", "sound"]);
|
|
27539
|
+
/**
|
|
27540
|
+
* One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
|
|
27541
|
+
* native panel and the PTZ mimic render as a button.
|
|
27542
|
+
*
|
|
27543
|
+
* - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
|
|
27544
|
+
* (pass to `runAction`); for `kind:'sound'` it is a namespaced id
|
|
27545
|
+
* (`sound:meow`) whose `soundId` is passed to `playSound`.
|
|
27546
|
+
* - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
|
|
27547
|
+
* - `label` — operator-facing English label.
|
|
27548
|
+
* - `soundId` — wire sound id, present only on `kind:'sound'` entries.
|
|
27549
|
+
* - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
|
|
27550
|
+
* PTZ render ONLY enabled entries. Data-driven: the provider
|
|
27551
|
+
* flips it from config, never by editing code.
|
|
27552
|
+
*/
|
|
27553
|
+
var NavigationActionEntrySchema = object({
|
|
27554
|
+
id: string(),
|
|
27555
|
+
kind: NavigationEntryKindSchema,
|
|
27556
|
+
label: string(),
|
|
27557
|
+
icon: string(),
|
|
27558
|
+
/** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
|
|
27559
|
+
soundId: number().int().optional(),
|
|
27560
|
+
/** Per-device feature flag — render this entry only when true. */
|
|
27561
|
+
enabled: boolean()
|
|
27562
|
+
});
|
|
27563
|
+
/** Coordinates for `goToPoint` — a point on the robot's live map. */
|
|
27564
|
+
var NavigationPointSchema = object({
|
|
27565
|
+
x: number(),
|
|
27566
|
+
y: number()
|
|
27567
|
+
});
|
|
27568
|
+
/**
|
|
27569
|
+
* Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
|
|
27570
|
+
* The cap reports which are enabled so the UI / PTZ render only the controls
|
|
27571
|
+
* that are turned on for THIS device. Data-driven: the provider derives these
|
|
27572
|
+
* from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
|
|
27573
|
+
* live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
|
|
27574
|
+
* that are not dictionary entries.
|
|
27575
|
+
*
|
|
27576
|
+
* - `move` / `stop` — the momentary drive joystick.
|
|
27577
|
+
* - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
|
|
27578
|
+
* map-coordinate plumbing is wired.
|
|
27579
|
+
* - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
|
|
27580
|
+
* - `playSound` — the sound buttons (dictionary `kind:'sound'`).
|
|
27581
|
+
* - `light` — the on/off fill-light toggle (works anytime).
|
|
27582
|
+
* - `lightMode` — the auto/manual selector + manual level slider (a
|
|
27583
|
+
* camera-service control; needs an active stream).
|
|
27584
|
+
*/
|
|
27585
|
+
var NavigationFeaturesSchema = object({
|
|
27586
|
+
move: boolean(),
|
|
27587
|
+
stop: boolean(),
|
|
27588
|
+
goToPoint: boolean(),
|
|
27589
|
+
runAction: boolean(),
|
|
27590
|
+
playSound: boolean(),
|
|
27591
|
+
light: boolean(),
|
|
27592
|
+
lightMode: boolean()
|
|
27593
|
+
});
|
|
27594
|
+
/** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
|
|
27595
|
+
var NavigationLightModeSchema = _enum(["auto", "manual"]);
|
|
27596
|
+
/**
|
|
27597
|
+
* Live navigation state so the UI can reflect what the robot is doing:
|
|
27598
|
+
* - `mode` — coarse activity (idle / cleaning / following / …).
|
|
27599
|
+
* - `following` — person/pet follow is currently armed.
|
|
27600
|
+
* - `flash` — the on-camera fill light is on.
|
|
27601
|
+
* - `lightMode` — auto vs manual fill-light mode.
|
|
27602
|
+
* - `lightLevel` — manual fill-light level (40..100); meaningful when
|
|
27603
|
+
* `lightMode === 'manual'`.
|
|
27604
|
+
*/
|
|
27605
|
+
var NavigationStatusSchema = object({
|
|
27606
|
+
mode: _enum([
|
|
27607
|
+
"idle",
|
|
27608
|
+
"cleaning",
|
|
27609
|
+
"spot",
|
|
27610
|
+
"following",
|
|
27611
|
+
"goto",
|
|
27612
|
+
"returning",
|
|
27613
|
+
"paused",
|
|
27614
|
+
"unknown"
|
|
27615
|
+
]),
|
|
27616
|
+
following: boolean(),
|
|
27617
|
+
flash: boolean(),
|
|
27618
|
+
lightMode: NavigationLightModeSchema,
|
|
27619
|
+
lightLevel: number().min(40).max(100),
|
|
27620
|
+
/** Ms epoch when the slice was last updated. */
|
|
27621
|
+
lastChangedAt: number()
|
|
27622
|
+
});
|
|
27623
|
+
NavigationStatusSchema.extend({ lastFetchedAt: number() });
|
|
27624
|
+
DeviceType.Camera, method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(NavigationActionEntrySchema)), method(object({
|
|
27625
|
+
deviceId: number(),
|
|
27626
|
+
actionId: NavigationActionIdSchema
|
|
27627
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27628
|
+
deviceId: number(),
|
|
27629
|
+
soundId: number().int()
|
|
27630
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27631
|
+
deviceId: number(),
|
|
27632
|
+
on: boolean()
|
|
27633
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27634
|
+
deviceId: number(),
|
|
27635
|
+
mode: NavigationLightModeSchema,
|
|
27636
|
+
level: number().min(40).max(100).optional()
|
|
27637
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27638
|
+
deviceId: number(),
|
|
27639
|
+
level: number().min(40).max(100)
|
|
27640
|
+
}), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
|
|
27641
|
+
deviceId: number(),
|
|
27642
|
+
status: NavigationStatusSchema
|
|
27643
|
+
});
|
|
27644
|
+
/**
|
|
27645
|
+
* Network-link snapshot. Same shape for every provider (a Reolink wifi
|
|
27646
|
+
* camera, a Home Assistant device with a signal-strength sensor, a Tapo
|
|
27647
|
+
* plug): one slice under `device.runtimeState['network-link']`, one badge,
|
|
27648
|
+
* one Home Assistant projection.
|
|
27649
|
+
*/
|
|
27650
|
+
var NetworkLinkStatusSchema = object({
|
|
27651
|
+
/** The link the device is on. `'unknown'` = not read yet, not "no link". */
|
|
27652
|
+
type: _enum([
|
|
27653
|
+
"wifi",
|
|
27654
|
+
"ethernet",
|
|
27655
|
+
"cellular",
|
|
27656
|
+
"unknown"
|
|
27657
|
+
]),
|
|
27658
|
+
/**
|
|
27659
|
+
* Link quality, 0..100 inclusive, normalised by the provider from whatever
|
|
27660
|
+
* the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
|
|
27661
|
+
* KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
|
|
27662
|
+
* one whose reading has not landed must not be drawn at 0 %. Consumers
|
|
27663
|
+
* SKIP a null rather than coerce it.
|
|
27664
|
+
*/
|
|
27665
|
+
signalPercent: number().min(0).max(100).nullable(),
|
|
27666
|
+
/** Raw received signal strength in dBm, when the firmware reports one. */
|
|
27667
|
+
rssiDbm: number().optional(),
|
|
27668
|
+
/** Network name of a wireless link, when the firmware reports it. */
|
|
27669
|
+
ssid: string().optional(),
|
|
27670
|
+
/** Ms epoch of the last observation. Lets consumers reason about freshness. */
|
|
27671
|
+
lastUpdated: number()
|
|
27672
|
+
});
|
|
27673
|
+
DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
|
|
27674
|
+
deviceId: number(),
|
|
27675
|
+
status: NetworkLinkStatusSchema
|
|
27676
|
+
});
|
|
27677
|
+
/**
|
|
27334
27678
|
* network-quality — system-scoped singleton capability tracking RTT,
|
|
27335
27679
|
* jitter, and observed/peak bandwidth per device + per client.
|
|
27336
27680
|
*
|
|
@@ -28572,203 +28916,6 @@ DeviceType.Camera, method(object({ deviceId: number() }), PtzAutotrackStatusSche
|
|
|
28572
28916
|
deviceId: number(),
|
|
28573
28917
|
status: PtzAutotrackStatusSchema
|
|
28574
28918
|
});
|
|
28575
|
-
/**
|
|
28576
|
-
* `navigation` — a device-scoped capability that natively expresses the FULL
|
|
28577
|
-
* navigation / action surface of a robot that DRIVES ITSELF and carries an
|
|
28578
|
-
* on-board camera (the Dreame robot-vacuum camera is the first provider).
|
|
28579
|
-
*
|
|
28580
|
-
* Why a NEW cap rather than overloading `ptz`:
|
|
28581
|
-
* - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
|
|
28582
|
-
* robot vacuum has no gimbal — the whole chassis drives, turns and spins.
|
|
28583
|
-
* The two are different physical models: PTZ is absolute-position + presets,
|
|
28584
|
-
* navigation is momentary drive nudges + discrete robot ACTIONS
|
|
28585
|
-
* (dock / spot-clean / follow-pet / go-to-point / …).
|
|
28586
|
-
* - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
|
|
28587
|
-
* the reverse:
|
|
28588
|
-
* 1. a native CamStack navigation panel (data-driven from `listActions`
|
|
28589
|
-
* / `getOptions`), and
|
|
28590
|
-
* 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
|
|
28591
|
-
* robot camera shows up in the existing PTZ control path without every
|
|
28592
|
-
* PTZ provider learning about robots. The mapping lives in the adapter,
|
|
28593
|
-
* not here (see the addon design note):
|
|
28594
|
-
* ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
|
|
28595
|
-
* ptz.stop() → navigation.stop()
|
|
28596
|
-
* ptz.goHome() → navigation.runAction('goHome')
|
|
28597
|
-
* ptz.getPresets() → navigation.listActions() (id→preset)
|
|
28598
|
-
* ptz.goToPreset(id) → navigation.runAction(id)
|
|
28599
|
-
*
|
|
28600
|
-
* ## Continuous drive
|
|
28601
|
-
*
|
|
28602
|
-
* `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
|
|
28603
|
-
* sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
|
|
28604
|
-
* one `stop()` on release — exactly like the robot app's remote-drive joystick.
|
|
28605
|
-
* The provider forwards EACH `move` to one drive write; it must NOT debounce or
|
|
28606
|
-
* coalesce them. The UI owns the cadence.
|
|
28607
|
-
*
|
|
28608
|
-
* ## The action dictionary
|
|
28609
|
-
*
|
|
28610
|
-
* The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
|
|
28611
|
-
* are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
|
|
28612
|
-
* carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
|
|
28613
|
-
* native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
|
|
28614
|
-
* vendor-specific list. `kind: 'action'` entries are triggered with
|
|
28615
|
-
* `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
|
|
28616
|
-
* (the entry carries the `soundId` to pass). The general primitives — `move`,
|
|
28617
|
-
* `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
|
|
28618
|
-
*
|
|
28619
|
-
* NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
|
|
28620
|
-
* `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
|
|
28621
|
-
* that the currently-published `@apocaliss92/nodedreame` already exposes on
|
|
28622
|
-
* every device handle. A future nodedreame publish adds a typed
|
|
28623
|
-
* `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
|
|
28624
|
-
* provider can then swap the raw calls for the typed methods with no change to
|
|
28625
|
-
* THIS contract.
|
|
28626
|
-
*/
|
|
28627
|
-
/**
|
|
28628
|
-
* A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
|
|
28629
|
-
* keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
|
|
28630
|
-
* halts it.
|
|
28631
|
-
*
|
|
28632
|
-
* - `pan` — turn: negative = left, positive = right, 0 = straight.
|
|
28633
|
-
* - `tilt` — throttle: positive = forward, negative = spin / turn-around.
|
|
28634
|
-
* - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
|
|
28635
|
-
* vector by it (drivers without proportional drive ignore it).
|
|
28636
|
-
*
|
|
28637
|
-
* `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
|
|
28638
|
-
* axis alone; an all-undefined nudge is a no-op.
|
|
28639
|
-
*/
|
|
28640
|
-
var NavigationMoveCommandSchema = object({
|
|
28641
|
-
pan: number().min(-1).max(1).optional(),
|
|
28642
|
-
tilt: number().min(-1).max(1).optional(),
|
|
28643
|
-
speed: number().min(0).max(1).optional()
|
|
28644
|
-
});
|
|
28645
|
-
/**
|
|
28646
|
-
* The enumerated discrete actions a navigation-capable robot can perform via
|
|
28647
|
-
* `runAction`. This is the CLOSED vocabulary; a given device advertises the
|
|
28648
|
-
* subset it supports through `listActions`. Sounds are NOT here — they go through
|
|
28649
|
-
* `playSound` (see the `sound` dictionary entries).
|
|
28650
|
-
*/
|
|
28651
|
-
var NavigationActionIdSchema = _enum([
|
|
28652
|
-
"goHome",
|
|
28653
|
-
"locate",
|
|
28654
|
-
"spotClean",
|
|
28655
|
-
"findPet",
|
|
28656
|
-
"personFollow",
|
|
28657
|
-
"stop",
|
|
28658
|
-
"startClean",
|
|
28659
|
-
"pauseClean",
|
|
28660
|
-
"dockWash",
|
|
28661
|
-
"autoEmpty",
|
|
28662
|
-
"flashOn",
|
|
28663
|
-
"flashOff"
|
|
28664
|
-
]);
|
|
28665
|
-
/** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
|
|
28666
|
-
var NavigationEntryKindSchema = _enum(["action", "sound"]);
|
|
28667
|
-
/**
|
|
28668
|
-
* One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
|
|
28669
|
-
* native panel and the PTZ mimic render as a button.
|
|
28670
|
-
*
|
|
28671
|
-
* - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
|
|
28672
|
-
* (pass to `runAction`); for `kind:'sound'` it is a namespaced id
|
|
28673
|
-
* (`sound:meow`) whose `soundId` is passed to `playSound`.
|
|
28674
|
-
* - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
|
|
28675
|
-
* - `label` — operator-facing English label.
|
|
28676
|
-
* - `soundId` — wire sound id, present only on `kind:'sound'` entries.
|
|
28677
|
-
* - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
|
|
28678
|
-
* PTZ render ONLY enabled entries. Data-driven: the provider
|
|
28679
|
-
* flips it from config, never by editing code.
|
|
28680
|
-
*/
|
|
28681
|
-
var NavigationActionEntrySchema = object({
|
|
28682
|
-
id: string(),
|
|
28683
|
-
kind: NavigationEntryKindSchema,
|
|
28684
|
-
label: string(),
|
|
28685
|
-
icon: string(),
|
|
28686
|
-
/** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
|
|
28687
|
-
soundId: number().int().optional(),
|
|
28688
|
-
/** Per-device feature flag — render this entry only when true. */
|
|
28689
|
-
enabled: boolean()
|
|
28690
|
-
});
|
|
28691
|
-
/** Coordinates for `goToPoint` — a point on the robot's live map. */
|
|
28692
|
-
var NavigationPointSchema = object({
|
|
28693
|
-
x: number(),
|
|
28694
|
-
y: number()
|
|
28695
|
-
});
|
|
28696
|
-
/**
|
|
28697
|
-
* Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
|
|
28698
|
-
* The cap reports which are enabled so the UI / PTZ render only the controls
|
|
28699
|
-
* that are turned on for THIS device. Data-driven: the provider derives these
|
|
28700
|
-
* from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
|
|
28701
|
-
* live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
|
|
28702
|
-
* that are not dictionary entries.
|
|
28703
|
-
*
|
|
28704
|
-
* - `move` / `stop` — the momentary drive joystick.
|
|
28705
|
-
* - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
|
|
28706
|
-
* map-coordinate plumbing is wired.
|
|
28707
|
-
* - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
|
|
28708
|
-
* - `playSound` — the sound buttons (dictionary `kind:'sound'`).
|
|
28709
|
-
* - `light` — the on/off fill-light toggle (works anytime).
|
|
28710
|
-
* - `lightMode` — the auto/manual selector + manual level slider (a
|
|
28711
|
-
* camera-service control; needs an active stream).
|
|
28712
|
-
*/
|
|
28713
|
-
var NavigationFeaturesSchema = object({
|
|
28714
|
-
move: boolean(),
|
|
28715
|
-
stop: boolean(),
|
|
28716
|
-
goToPoint: boolean(),
|
|
28717
|
-
runAction: boolean(),
|
|
28718
|
-
playSound: boolean(),
|
|
28719
|
-
light: boolean(),
|
|
28720
|
-
lightMode: boolean()
|
|
28721
|
-
});
|
|
28722
|
-
/** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
|
|
28723
|
-
var NavigationLightModeSchema = _enum(["auto", "manual"]);
|
|
28724
|
-
/**
|
|
28725
|
-
* Live navigation state so the UI can reflect what the robot is doing:
|
|
28726
|
-
* - `mode` — coarse activity (idle / cleaning / following / …).
|
|
28727
|
-
* - `following` — person/pet follow is currently armed.
|
|
28728
|
-
* - `flash` — the on-camera fill light is on.
|
|
28729
|
-
* - `lightMode` — auto vs manual fill-light mode.
|
|
28730
|
-
* - `lightLevel` — manual fill-light level (40..100); meaningful when
|
|
28731
|
-
* `lightMode === 'manual'`.
|
|
28732
|
-
*/
|
|
28733
|
-
var NavigationStatusSchema = object({
|
|
28734
|
-
mode: _enum([
|
|
28735
|
-
"idle",
|
|
28736
|
-
"cleaning",
|
|
28737
|
-
"spot",
|
|
28738
|
-
"following",
|
|
28739
|
-
"goto",
|
|
28740
|
-
"returning",
|
|
28741
|
-
"paused",
|
|
28742
|
-
"unknown"
|
|
28743
|
-
]),
|
|
28744
|
-
following: boolean(),
|
|
28745
|
-
flash: boolean(),
|
|
28746
|
-
lightMode: NavigationLightModeSchema,
|
|
28747
|
-
lightLevel: number().min(40).max(100),
|
|
28748
|
-
/** Ms epoch when the slice was last updated. */
|
|
28749
|
-
lastChangedAt: number()
|
|
28750
|
-
});
|
|
28751
|
-
NavigationStatusSchema.extend({ lastFetchedAt: number() });
|
|
28752
|
-
DeviceType.Camera, method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(NavigationActionEntrySchema)), method(object({
|
|
28753
|
-
deviceId: number(),
|
|
28754
|
-
actionId: NavigationActionIdSchema
|
|
28755
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28756
|
-
deviceId: number(),
|
|
28757
|
-
soundId: number().int()
|
|
28758
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28759
|
-
deviceId: number(),
|
|
28760
|
-
on: boolean()
|
|
28761
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28762
|
-
deviceId: number(),
|
|
28763
|
-
mode: NavigationLightModeSchema,
|
|
28764
|
-
level: number().min(40).max(100).optional()
|
|
28765
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28766
|
-
deviceId: number(),
|
|
28767
|
-
level: number().min(40).max(100)
|
|
28768
|
-
}), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
|
|
28769
|
-
deviceId: number(),
|
|
28770
|
-
status: NavigationStatusSchema
|
|
28771
|
-
});
|
|
28772
28919
|
DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
|
|
28773
28920
|
kind: "mutation",
|
|
28774
28921
|
auth: "admin"
|
|
@@ -29455,8 +29602,38 @@ var RecordingSignalStatusSchema = object({
|
|
|
29455
29602
|
/** Ms epoch of the last `active` transition. 0 if never observed. */
|
|
29456
29603
|
lastChangedAt: number()
|
|
29457
29604
|
});
|
|
29458
|
-
|
|
29459
|
-
|
|
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
|
+
};
|
|
29460
29637
|
/**
|
|
29461
29638
|
* scene-monitor — device-scoped reference-region state cap. An operator marks
|
|
29462
29639
|
* a rect ROI on a camera frame and names one or more states; the engine
|
|
@@ -36167,6 +36344,12 @@ Object.freeze({
|
|
|
36167
36344
|
addonId: null,
|
|
36168
36345
|
access: "view"
|
|
36169
36346
|
},
|
|
36347
|
+
"storage.listDrainProgress": {
|
|
36348
|
+
capName: "storage",
|
|
36349
|
+
capScope: "system",
|
|
36350
|
+
addonId: null,
|
|
36351
|
+
access: "view"
|
|
36352
|
+
},
|
|
36170
36353
|
"storage.listLocationDeclarations": {
|
|
36171
36354
|
capName: "storage",
|
|
36172
36355
|
capScope: "system",
|
|
@@ -36311,6 +36494,12 @@ Object.freeze({
|
|
|
36311
36494
|
addonId: null,
|
|
36312
36495
|
access: "view"
|
|
36313
36496
|
},
|
|
36497
|
+
"storageOccupancy.getOccupancy": {
|
|
36498
|
+
capName: "storage-occupancy",
|
|
36499
|
+
capScope: "system",
|
|
36500
|
+
addonId: null,
|
|
36501
|
+
access: "view"
|
|
36502
|
+
},
|
|
36314
36503
|
"storageProvider.abortUpload": {
|
|
36315
36504
|
capName: "storage-provider",
|
|
36316
36505
|
capScope: "system",
|
|
@@ -39920,702 +40109,113 @@ function deviceBackendToFormat(backend) {
|
|
|
39920
40109
|
return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
|
|
39921
40110
|
}
|
|
39922
40111
|
//#endregion
|
|
39923
|
-
//#region src/
|
|
40112
|
+
//#region src/audio-chunk-poller.ts
|
|
39924
40113
|
/**
|
|
39925
|
-
*
|
|
39926
|
-
*
|
|
40114
|
+
* `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
|
|
40115
|
+
* plane (Phase 5 / D9).
|
|
39927
40116
|
*
|
|
39928
|
-
*
|
|
39929
|
-
*
|
|
39930
|
-
*
|
|
39931
|
-
*
|
|
39932
|
-
* and the detection-pipeline are separate addons; only tRPC crosses the
|
|
39933
|
-
* boundary). Keep this in sync with `default-detection-model.ts` /
|
|
39934
|
-
* `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.
|
|
39935
40121
|
*
|
|
39936
|
-
* The
|
|
39937
|
-
* - `yolov9m-320-int8` — Intel NPU + iGPU (yolo26 does NOT compile on the NPU)
|
|
39938
|
-
* - `yolov9m-320` — Apple ANE (CoreML)
|
|
39939
|
-
* - `ssd-mobilenet-v2-coco-edgetpu` — Coral USB Edge TPU (tflite)
|
|
39940
|
-
* - `yolo26n` — CPU / CUDA (the object-detection step's universal
|
|
39941
|
-
* nano default)
|
|
40122
|
+
* The consumer:
|
|
39942
40123
|
*
|
|
39943
|
-
*
|
|
39944
|
-
*
|
|
39945
|
-
*
|
|
39946
|
-
|
|
39947
|
-
|
|
39948
|
-
*
|
|
39949
|
-
*
|
|
39950
|
-
* cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
|
|
39951
|
-
* import is forbidden — this is the same duplication rationale as the model
|
|
39952
|
-
* defaults above).
|
|
39953
|
-
*/
|
|
39954
|
-
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
39955
|
-
/**
|
|
39956
|
-
* Build the camera-root capability predicate for a node from its live catalog:
|
|
39957
|
-
* `format → canHostCameraRoot`. A format can host a camera root iff the
|
|
39958
|
-
* catalog lists at least one object-detection model with a build for that
|
|
39959
|
-
* format — byte-for-byte the resolver's per-device skip-gate test for the root
|
|
39960
|
-
* step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
|
|
39961
|
-
* would ACTUALLY provision on it.
|
|
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`.
|
|
39962
40131
|
*
|
|
39963
|
-
*
|
|
39964
|
-
*
|
|
39965
|
-
*
|
|
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.
|
|
39966
40145
|
*/
|
|
39967
|
-
|
|
39968
|
-
|
|
39969
|
-
|
|
39970
|
-
|
|
39971
|
-
}
|
|
39972
|
-
return () => true;
|
|
39973
|
-
}
|
|
39974
|
-
/** Split a deviceKey (`<backend>:<device>`, or bare `cpu`) into its parts + format.
|
|
39975
|
-
* Format comes from the shared {@link deviceBackendToFormat} SSOT (`@camstack/types`)
|
|
39976
|
-
* — the previously-local `BACKEND_FORMAT` copy is gone (R3/node-F2). Used only for
|
|
39977
|
-
* STORED-ONLY keys (a configured device the live probe didn't return); a probed
|
|
39978
|
-
* device carries its own honest `format` from the descriptor. */
|
|
39979
|
-
function parseDeviceKey(deviceKey) {
|
|
39980
|
-
const colon = deviceKey.indexOf(":");
|
|
39981
|
-
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
39982
|
-
return {
|
|
39983
|
-
backend,
|
|
39984
|
-
device: colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey,
|
|
39985
|
-
format: deviceBackendToFormat(backend)
|
|
39986
|
-
};
|
|
39987
|
-
}
|
|
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;
|
|
39988
40150
|
/**
|
|
39989
|
-
*
|
|
39990
|
-
*
|
|
39991
|
-
*
|
|
39992
|
-
*
|
|
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.
|
|
39993
40155
|
*/
|
|
39994
|
-
|
|
39995
|
-
|
|
39996
|
-
|
|
39997
|
-
|
|
39998
|
-
|
|
39999
|
-
}
|
|
40000
|
-
if (backend === "edgetpu") return "ssd-mobilenet-v2-coco-edgetpu";
|
|
40001
|
-
if (backend === "coreml") return "yolov9m-320";
|
|
40002
|
-
return "yolo26n";
|
|
40003
|
-
}
|
|
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;
|
|
40004
40161
|
/**
|
|
40005
|
-
*
|
|
40006
|
-
*
|
|
40007
|
-
*
|
|
40008
|
-
* device. `enabledAvailableKeys` is the effective enabled∧available set (from
|
|
40009
|
-
* `mergeInferenceDevices(probe, submitted)`) so an absent/unplugged/disabled
|
|
40010
|
-
* target is rejected honestly (an operator can't route a step onto a dead pool).
|
|
40011
|
-
* Returns the FIRST human-readable error, or `null` when every override is
|
|
40012
|
-
* valid. 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.
|
|
40013
40165
|
*/
|
|
40014
|
-
|
|
40015
|
-
for (const [deviceKey, entry] of Object.entries(inferenceDevices)) for (const [stepId, step] of Object.entries(entry.steps ?? {})) {
|
|
40016
|
-
const target = step.jumpDeviceKey;
|
|
40017
|
-
if (target === void 0) continue;
|
|
40018
|
-
if (target === deviceKey) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey pointing at its own device`;
|
|
40019
|
-
if (!enabledAvailableKeys.has(target)) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey "${target}" that is not an enabled, available device on this node`;
|
|
40020
|
-
}
|
|
40021
|
-
return null;
|
|
40022
|
-
}
|
|
40166
|
+
var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
|
|
40023
40167
|
/**
|
|
40024
|
-
*
|
|
40025
|
-
*
|
|
40026
|
-
*
|
|
40027
|
-
*
|
|
40028
|
-
*
|
|
40029
|
-
*
|
|
40030
|
-
*
|
|
40031
|
-
*
|
|
40032
|
-
* NPU/iGPU/ANE. CPU stays the always-available FALLBACK (a node with no
|
|
40033
|
-
* eligible accelerator leaves `deviceKey` unset → the runner's default
|
|
40034
|
-
* pool, which is CPU), not a balanced target — matching the spec's "no
|
|
40035
|
-
* device eligible → fall back to CPU".
|
|
40036
|
-
* - **Coral Edge TPU (`edgetpu`)**: the standing rule since the Coral
|
|
40037
|
-
* executor landed is that it surfaces as selectable but is NEVER
|
|
40038
|
-
* auto-picked — it runs a DIFFERENT, weaker model family (tflite SSD
|
|
40039
|
-
* MobileNet, not the YOLO the other accelerators run), so silently
|
|
40040
|
-
* enrolling a plugged-in Coral changes detection QUALITY, not just
|
|
40041
|
-
* placement. The opt-OUT default did exactly that on 2026-08-01: a hub
|
|
40042
|
-
* Coral nobody enabled entered the session rotation and camera 615 spent
|
|
40043
|
-
* hours at 2.4fps failing tflite model resolution. An operator who wants
|
|
40044
|
-
* the Coral balanced opts it in explicitly (`enabled: true`).
|
|
40045
|
-
*
|
|
40046
|
-
* So: an NPU/iGPU/ANE accelerator with NO stored entry is `enabled:true`; a
|
|
40047
|
-
* CPU or edgetpu device with no stored entry is `enabled:false`; an explicit
|
|
40048
|
-
* stored `enabled` always wins (an operator can opt CPU/Coral in, or an
|
|
40049
|
-
* accelerator out). A stored-only key (configured but the probe did not
|
|
40050
|
-
* return it — removed/unplugged HW) keeps its stored `enabled` and surfaces
|
|
40051
|
-
* as `available:false`, so the UI still shows it.
|
|
40052
|
-
*
|
|
40053
|
-
* Pure + deterministic (sorted by key) — the single merge authority shared by
|
|
40054
|
-
* the `getNodeInferenceDevices` view and the dispatcher's eligible-device pick.
|
|
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.
|
|
40055
40176
|
*/
|
|
40056
|
-
|
|
40057
|
-
|
|
40058
|
-
const keys = new Set([...probedByKey.keys(), ...Object.keys(stored)]);
|
|
40059
|
-
const out = [];
|
|
40060
|
-
for (const key of Array.from(keys).toSorted()) {
|
|
40061
|
-
const descriptor = probedByKey.get(key);
|
|
40062
|
-
const opt = stored[key];
|
|
40063
|
-
const parsed = descriptor ?? parseDeviceKey(key);
|
|
40064
|
-
const weight = opt?.weight !== void 0 && opt.weight > 0 ? opt.weight : 1;
|
|
40065
|
-
const autoDefault = parsed.backend !== "cpu" && parsed.backend !== "edgetpu";
|
|
40066
|
-
out.push({
|
|
40067
|
-
key,
|
|
40068
|
-
backend: parsed.backend,
|
|
40069
|
-
device: parsed.device,
|
|
40070
|
-
format: parsed.format,
|
|
40071
|
-
available: descriptor?.available ?? false,
|
|
40072
|
-
enabled: opt?.enabled ?? autoDefault,
|
|
40073
|
-
weight,
|
|
40074
|
-
maxSessions: opt?.maxSessions ?? null,
|
|
40075
|
-
defaultModelId: defaultModelIdForDevice(key),
|
|
40076
|
-
...opt?.steps && Object.keys(opt.steps).length > 0 ? { steps: { ...opt.steps } } : {}
|
|
40077
|
-
});
|
|
40078
|
-
}
|
|
40079
|
-
return out;
|
|
40080
|
-
}
|
|
40177
|
+
var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
|
|
40178
|
+
var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
|
|
40081
40179
|
/**
|
|
40082
|
-
*
|
|
40083
|
-
*
|
|
40084
|
-
*
|
|
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.
|
|
40085
40186
|
*/
|
|
40086
|
-
function
|
|
40087
|
-
const
|
|
40088
|
-
|
|
40089
|
-
|
|
40090
|
-
|
|
40091
|
-
|
|
40092
|
-
|
|
40093
|
-
|
|
40094
|
-
|
|
40095
|
-
|
|
40096
|
-
|
|
40097
|
-
|
|
40098
|
-
|
|
40099
|
-
const acceleratorServes = merged.some((d) => !isCpuFallback(d) && d.enabled && d.available && (!canRunRoot || canRunRoot(d.format)));
|
|
40100
|
-
for (const d of merged) {
|
|
40101
|
-
if (!d.enabled) {
|
|
40102
|
-
excluded.push({
|
|
40103
|
-
key: d.key,
|
|
40104
|
-
reason: "disabled",
|
|
40105
|
-
format: d.format
|
|
40106
|
-
});
|
|
40107
|
-
continue;
|
|
40108
|
-
}
|
|
40109
|
-
if (!d.available) {
|
|
40110
|
-
excluded.push({
|
|
40111
|
-
key: d.key,
|
|
40112
|
-
reason: "unavailable",
|
|
40113
|
-
format: d.format
|
|
40114
|
-
});
|
|
40115
|
-
continue;
|
|
40116
|
-
}
|
|
40117
|
-
if (isPoolUsable && !isPoolUsable(d.key)) {
|
|
40118
|
-
excluded.push({
|
|
40119
|
-
key: d.key,
|
|
40120
|
-
reason: "unavailable",
|
|
40121
|
-
format: d.format
|
|
40122
|
-
});
|
|
40123
|
-
continue;
|
|
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;
|
|
40124
40200
|
}
|
|
40125
|
-
if (
|
|
40126
|
-
|
|
40127
|
-
|
|
40128
|
-
reason: "cannot-host-camera-root",
|
|
40129
|
-
format: d.format
|
|
40130
|
-
});
|
|
40131
|
-
continue;
|
|
40201
|
+
if (lifecycle.pollTimer) {
|
|
40202
|
+
clearTimeout(lifecycle.pollTimer);
|
|
40203
|
+
lifecycle.pollTimer = void 0;
|
|
40132
40204
|
}
|
|
40133
|
-
|
|
40134
|
-
|
|
40135
|
-
|
|
40136
|
-
|
|
40137
|
-
|
|
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
|
+
} });
|
|
40138
40214
|
});
|
|
40139
|
-
continue;
|
|
40140
40215
|
}
|
|
40141
|
-
eligible[d.key] = d.weight;
|
|
40142
|
-
}
|
|
40143
|
-
return {
|
|
40144
|
-
eligible,
|
|
40145
|
-
excluded
|
|
40146
40216
|
};
|
|
40147
|
-
|
|
40148
|
-
|
|
40149
|
-
* Join the merged device rows with the eligibility verdict so the UI can NAME
|
|
40150
|
-
* why an accelerator is not in play instead of leaving the operator to deduce
|
|
40151
|
-
* it from `enabled`/`available`.
|
|
40152
|
-
*
|
|
40153
|
-
* Deduction is not possible for two of the four reasons — `accelerator-preferred`
|
|
40154
|
-
* is a node-WIDE rule (a CPU row reads `enabled:true, available:true` and still
|
|
40155
|
-
* never gets a session, D215) and `cannot-host-camera-root` needs the node's
|
|
40156
|
-
* model catalog. Both live in {@link resolveInferenceDeviceEligibility}, so this
|
|
40157
|
-
* function only transports its answer; it never re-derives one.
|
|
40158
|
-
*
|
|
40159
|
-
* Pure; preserves `merged`'s order (sorted by key) and every other field.
|
|
40160
|
-
*/
|
|
40161
|
-
function annotateInferenceDeviceExclusions(merged, eligibility) {
|
|
40162
|
-
const reasonByKey = new Map(eligibility.excluded.map((e) => [e.key, e.reason]));
|
|
40163
|
-
return merged.map((view) => ({
|
|
40164
|
-
...view,
|
|
40165
|
-
exclusion: reasonByKey.get(view.key) ?? null
|
|
40166
|
-
}));
|
|
40167
|
-
}
|
|
40168
|
-
function resolveNodeInferenceUsability(eligibility) {
|
|
40169
|
-
const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
|
|
40170
|
-
const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
|
|
40171
|
-
return {
|
|
40172
|
-
usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
|
|
40173
|
-
unavailableKeys,
|
|
40174
|
-
eligibleKeys
|
|
40175
|
-
};
|
|
40176
|
-
}
|
|
40177
|
-
/**
|
|
40178
|
-
* Step-tree device jump (phase 1): the attach-payload roster of a node's
|
|
40179
|
-
* enabled∧available inference devices with the balancer knobs (`weight`,
|
|
40180
|
-
* `maxSessions`) the runner uses to AUTO-jump an enrichment step off a device
|
|
40181
|
-
* whose format can't run it. Built from the SAME `eligible` (deviceKey→weight)
|
|
40182
|
-
* and `caps` (deviceKey→maxSessions) the dispatcher already computes, so the
|
|
40183
|
-
* roster the runner sees exactly matches the balancer's candidate set. Sorted
|
|
40184
|
-
* by key for determinism. Populated onto `RunnerCameraConfig.inferenceDevices`
|
|
40185
|
-
* ONLY when a `deviceKey` is elected and there are ≥2 entries.
|
|
40186
|
-
*/
|
|
40187
|
-
function buildInferenceDeviceRoster(eligible, caps) {
|
|
40188
|
-
return Object.entries(eligible).map(([deviceKey, weight]) => ({
|
|
40189
|
-
deviceKey,
|
|
40190
|
-
weight: weight > 0 ? weight : 1,
|
|
40191
|
-
maxSessions: caps[deviceKey] ?? null
|
|
40192
|
-
})).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
|
|
40193
|
-
}
|
|
40194
|
-
//#endregion
|
|
40195
|
-
//#region src/node-inference-usability-mirror.ts
|
|
40196
|
-
var NodeInferenceUsabilityMirror = class {
|
|
40197
|
-
state = /* @__PURE__ */ new Map();
|
|
40198
|
-
/**
|
|
40199
|
-
* Fold one observation in and report whether the caller should act.
|
|
40200
|
-
* Never throws.
|
|
40201
|
-
*/
|
|
40202
|
-
observe(nodeId, usable) {
|
|
40203
|
-
const prev = this.state.get(nodeId);
|
|
40204
|
-
if (usable) {
|
|
40205
|
-
this.state.set(nodeId, {
|
|
40206
|
-
usable: true,
|
|
40207
|
-
armed: false
|
|
40208
|
-
});
|
|
40209
|
-
return prev !== void 0 && !prev.usable ? "recovered" : null;
|
|
40210
|
-
}
|
|
40211
|
-
if (prev === void 0) {
|
|
40212
|
-
this.state.set(nodeId, {
|
|
40213
|
-
usable: true,
|
|
40214
|
-
armed: true
|
|
40215
|
-
});
|
|
40216
|
-
return null;
|
|
40217
|
-
}
|
|
40218
|
-
if (!prev.usable) {
|
|
40219
|
-
this.state.set(nodeId, {
|
|
40220
|
-
usable: false,
|
|
40221
|
-
armed: true
|
|
40222
|
-
});
|
|
40223
|
-
return null;
|
|
40224
|
-
}
|
|
40225
|
-
if (!prev.armed) {
|
|
40226
|
-
this.state.set(nodeId, {
|
|
40227
|
-
usable: true,
|
|
40228
|
-
armed: true
|
|
40229
|
-
});
|
|
40230
|
-
return null;
|
|
40231
|
-
}
|
|
40232
|
-
this.state.set(nodeId, {
|
|
40233
|
-
usable: false,
|
|
40234
|
-
armed: true
|
|
40235
|
-
});
|
|
40236
|
-
return "became-unusable";
|
|
40237
|
-
}
|
|
40238
|
-
/** Can this node be given cameras? Unknown nodes answer YES. */
|
|
40239
|
-
isUsable(nodeId) {
|
|
40240
|
-
return this.state.get(nodeId)?.usable ?? true;
|
|
40241
|
-
}
|
|
40242
|
-
/** Nodes currently excluded — for the placement log and diagnostics. */
|
|
40243
|
-
unusableNodeIds() {
|
|
40244
|
-
const out = [];
|
|
40245
|
-
for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
|
|
40246
|
-
return out.toSorted();
|
|
40247
|
-
}
|
|
40248
|
-
forget(nodeId) {
|
|
40249
|
-
this.state.delete(nodeId);
|
|
40250
|
-
}
|
|
40251
|
-
reset() {
|
|
40252
|
-
this.state.clear();
|
|
40253
|
-
}
|
|
40254
|
-
};
|
|
40255
|
-
//#endregion
|
|
40256
|
-
//#region src/inference-device-usability-mirror.ts
|
|
40257
|
-
/**
|
|
40258
|
-
* InferenceDeviceUsabilityMirror — "can this NODE's THIS DEVICE still infer?",
|
|
40259
|
-
* kept off the placement path.
|
|
40260
|
-
*
|
|
40261
|
-
* The per-`(nodeId, deviceKey)` twin of {@link NodeInferenceUsabilityMirror},
|
|
40262
|
-
* and deliberately not a second mechanism: it composes the key and delegates
|
|
40263
|
-
* every decision to that class, so the arm/apply reluctance D49 pinned lives in
|
|
40264
|
-
* exactly one implementation and cannot drift between the node tier and the
|
|
40265
|
-
* device tier.
|
|
40266
|
-
*
|
|
40267
|
-
* ## Why this tier had to exist
|
|
40268
|
-
*
|
|
40269
|
-
* The node tier already answers "does this node have ANY usable accelerator".
|
|
40270
|
-
* On 2026-08-25 the hub's answer was, correctly, yes — its NPU was healthy the
|
|
40271
|
-
* whole time. What died was one DEVICE, `openvino:gpu`, and nothing anywhere
|
|
40272
|
-
* asked that question: the per-dispatch capability gate is keyed on model
|
|
40273
|
-
* FORMAT and `openvino:gpu` and `openvino:npu` share the format `openvino`, so
|
|
40274
|
-
* it is blind between them by construction. The balancer kept rotating cameras
|
|
40275
|
-
* onto the dead pool — 20 sessions in 20 minutes, every one `tieBreak:
|
|
40276
|
-
* "rotation"` — for 31 hours.
|
|
40277
|
-
*
|
|
40278
|
-
* ## Why a mirror and not the event
|
|
40279
|
-
*
|
|
40280
|
-
* D49, verbatim: *a one-shot event never gates on a fallible read*. The gate is
|
|
40281
|
-
* this in-memory mirror, refreshed off the event path by the same
|
|
40282
|
-
* `resolveEligibleInferenceDevices` the dispatcher already runs (plus the
|
|
40283
|
-
* session controller's background refresher). The consequences that buys:
|
|
40284
|
-
*
|
|
40285
|
-
* - **A read that fails changes nothing.** The caller folds in an observation
|
|
40286
|
-
* only when it HAS one; an unreachable node, a version-skewed executor or a
|
|
40287
|
-
* rejected RPC never reaches {@link observe}, so the previous verdict
|
|
40288
|
-
* stands. This is the whole reason the health read is specified as
|
|
40289
|
-
* "synchronous over in-memory state, never throws for its own reasons": an
|
|
40290
|
-
* empty answer must mean *nothing is refused*, not *I could not tell*.
|
|
40291
|
-
* - **Excluding a healthy device needs a second, consecutive read.** Exclusion
|
|
40292
|
-
* is the direction that DESTROYS work — it strands an accelerator that may
|
|
40293
|
-
* be perfectly fine — so one bad observation only ARMS.
|
|
40294
|
-
* - **Re-admitting is immediate and unconditional.** One good observation puts
|
|
40295
|
-
* the device straight back. Being slow to exclude costs some wasted
|
|
40296
|
-
* inference attempts; being slow to re-admit costs an idle accelerator and a
|
|
40297
|
-
* node that looks broken.
|
|
40298
|
-
*/
|
|
40299
|
-
/**
|
|
40300
|
-
* NUL — impossible in both a Moleculer node id and a `<backend>:<device>` key,
|
|
40301
|
-
* so the composite key can never be ambiguous. A separator that CAN occur in
|
|
40302
|
-
* either half makes two distinct pairs collide, and a collision here silently
|
|
40303
|
-
* excludes an accelerator nobody reported.
|
|
40304
|
-
*/
|
|
40305
|
-
var SEPARATOR = "\0";
|
|
40306
|
-
var InferenceDeviceUsabilityMirror = class {
|
|
40307
|
-
/** The one implementation of the arm/apply state machine (D49). */
|
|
40308
|
-
mirror = new NodeInferenceUsabilityMirror();
|
|
40309
|
-
/**
|
|
40310
|
-
* Fold one observation in and report whether the caller should act.
|
|
40311
|
-
* Never throws.
|
|
40312
|
-
*/
|
|
40313
|
-
observe(nodeId, deviceKey, usable) {
|
|
40314
|
-
return this.mirror.observe(`${nodeId}${SEPARATOR}${deviceKey}`, usable);
|
|
40315
|
-
}
|
|
40316
|
-
/** Can the balancer put a session on this device? Unknown pairs answer YES. */
|
|
40317
|
-
isUsable(nodeId, deviceKey) {
|
|
40318
|
-
return this.mirror.isUsable(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
40319
|
-
}
|
|
40320
|
-
/** Pairs currently excluded — for the placement log and diagnostics. */
|
|
40321
|
-
unusableDevices() {
|
|
40322
|
-
return this.mirror.unusableNodeIds().map((composite) => {
|
|
40323
|
-
const at = composite.indexOf(SEPARATOR);
|
|
40324
|
-
return {
|
|
40325
|
-
nodeId: composite.slice(0, at),
|
|
40326
|
-
deviceKey: composite.slice(at + 1)
|
|
40327
|
-
};
|
|
40328
|
-
});
|
|
40329
|
-
}
|
|
40330
|
-
/** The excluded device keys on ONE node. */
|
|
40331
|
-
unusableDeviceKeys(nodeId) {
|
|
40332
|
-
return this.unusableDevices().filter((d) => d.nodeId === nodeId).map((d) => d.deviceKey);
|
|
40333
|
-
}
|
|
40334
|
-
forget(nodeId, deviceKey) {
|
|
40335
|
-
this.mirror.forget(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
40336
|
-
}
|
|
40337
|
-
reset() {
|
|
40338
|
-
this.mirror.reset();
|
|
40339
|
-
}
|
|
40340
|
-
};
|
|
40341
|
-
/**
|
|
40342
|
-
* Fold ONE node's health answer into the mirror and return what changed.
|
|
40343
|
-
*
|
|
40344
|
-
* This is the whole reading discipline, in one place, because both halves of it
|
|
40345
|
-
* are easy to get subtly wrong and neither failure is visible in a log:
|
|
40346
|
-
*
|
|
40347
|
-
* - **`unhealthy === null` — the read FAILED — changes nothing.** Not a single
|
|
40348
|
-
* entry is touched. An unreachable node, a version-skewed executor or a
|
|
40349
|
-
* rejected RPC must be distinguishable from "asked, nothing is refused", or
|
|
40350
|
-
* a flaky link silently re-admits a dead accelerator (D49).
|
|
40351
|
-
* - **Every device the node HAS is observed**, not merely the refused ones.
|
|
40352
|
-
* The first draft observed `refused ∪ already-excluded`, which omits exactly
|
|
40353
|
-
* the devices the mirror has ARMED — so their disarming good read never
|
|
40354
|
-
* arrived and two bad reads an HOUR apart, with a hundred healthy ones
|
|
40355
|
-
* between them, excluded a working accelerator. "Consecutive" is only a
|
|
40356
|
-
* property if the good observations are delivered.
|
|
40357
|
-
*
|
|
40358
|
-
* Pure with respect to everything except `mirror`, and never throws — it is
|
|
40359
|
-
* called from the dispatcher's own read path.
|
|
40360
|
-
*/
|
|
40361
|
-
function foldInferenceDeviceHealth(mirror, nodeId, unhealthy, present) {
|
|
40362
|
-
if (unhealthy === null) return [];
|
|
40363
|
-
const refused = new Set(unhealthy);
|
|
40364
|
-
const observed = new Set([
|
|
40365
|
-
...present,
|
|
40366
|
-
...refused,
|
|
40367
|
-
...mirror.unusableDeviceKeys(nodeId)
|
|
40368
|
-
]);
|
|
40369
|
-
const changes = [];
|
|
40370
|
-
for (const deviceKey of observed) {
|
|
40371
|
-
const transition = mirror.observe(nodeId, deviceKey, !refused.has(deviceKey));
|
|
40372
|
-
if (transition !== null) changes.push({
|
|
40373
|
-
deviceKey,
|
|
40374
|
-
transition
|
|
40375
|
-
});
|
|
40376
|
-
}
|
|
40377
|
-
return changes;
|
|
40378
|
-
}
|
|
40379
|
-
//#endregion
|
|
40380
|
-
//#region src/orchestrator-types.ts
|
|
40381
|
-
var PHASE_MODE_VALUES = new Set([
|
|
40382
|
-
"disabled",
|
|
40383
|
-
"always-on",
|
|
40384
|
-
"on-motion"
|
|
40385
|
-
]);
|
|
40386
|
-
function isPipelinePhaseMode(v) {
|
|
40387
|
-
return PHASE_MODE_VALUES.has(v);
|
|
40388
|
-
}
|
|
40389
|
-
/**
|
|
40390
|
-
* Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
|
|
40391
|
-
* that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
|
|
40392
|
-
* `reconcileDispatch` is additive-only and never revisits these, so a slow
|
|
40393
|
-
* safety-net timer + event-driven debounce triggers recover them.
|
|
40394
|
-
*/
|
|
40395
|
-
var PENDING_RETRY_INTERVAL_MS = 6e4;
|
|
40396
|
-
/** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
|
|
40397
|
-
var PENDING_RETRY_DEBOUNCE_MS = 2e3;
|
|
40398
|
-
/**
|
|
40399
|
-
* Periodic auto-rebalance sweep. New attaches are already load-balanced at
|
|
40400
|
-
* dispatch time; this corrects DRIFT that accumulates over time (uneven
|
|
40401
|
-
* detach, a node returning online, a weight change) so the steady-state
|
|
40402
|
-
* distribution tracks the per-node weights. Only runs with ≥2 enabled detect
|
|
40403
|
-
* nodes, and migrates under hysteresis so a balanced cluster is a no-op.
|
|
40404
|
-
*/
|
|
40405
|
-
var AUTO_REBALANCE_INTERVAL_MS = 6e4;
|
|
40406
|
-
/**
|
|
40407
|
-
* Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
|
|
40408
|
-
* migrate a camera only when its target node is at least this much less loaded
|
|
40409
|
-
* than its current node. > 1 so equalizing a single-camera gap (which would
|
|
40410
|
-
* only reverse the imbalance) is skipped — prevents periodic churn.
|
|
40411
|
-
*/
|
|
40412
|
-
var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
|
|
40413
|
-
var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
|
|
40414
|
-
/**
|
|
40415
|
-
* Device-details keys routed through the orchestrator's pipeline
|
|
40416
|
-
* settings writer instead of the device orchestration store. The
|
|
40417
|
-
* `cameraPipeline` key carries the full `CameraPipelineConfig`
|
|
40418
|
-
* emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
|
|
40419
|
-
*/
|
|
40420
|
-
var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
|
|
40421
|
-
var DEFAULT_FAILOVER_POLICY = {
|
|
40422
|
-
onDisconnect: "migrate",
|
|
40423
|
-
pinnedOnDisconnect: "leave-pinned",
|
|
40424
|
-
onReconnect: "restore"
|
|
40425
|
-
};
|
|
40426
|
-
/**
|
|
40427
|
-
* Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
|
|
40428
|
-
*
|
|
40429
|
-
* The orchestrator's cap surface is the contract for all runtime traffic
|
|
40430
|
-
* (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
|
|
40431
|
-
* catalog is reserved for read-only diagnostics that are intentionally
|
|
40432
|
-
* outside the cap — they expose internal state (balancer caches, enabledNodes
|
|
40433
|
-
* set, active detection count) that is useful for admin tooling but does not
|
|
40434
|
-
* belong on the capability contract.
|
|
40435
|
-
*/
|
|
40436
|
-
var OrchestratorDiagnosticsSchema = object({
|
|
40437
|
-
localNodeId: string(),
|
|
40438
|
-
knownRunnerNodes: array(string()),
|
|
40439
|
-
cachedAgentLoadNodeIds: array(string()),
|
|
40440
|
-
enabledNodes: array(string()),
|
|
40441
|
-
enabledDecoderNodes: array(string()),
|
|
40442
|
-
enabledAudioNodes: array(string()),
|
|
40443
|
-
enabledIngestNodes: array(string()),
|
|
40444
|
-
clusterRoles: object({
|
|
40445
|
-
ingestNode: string(),
|
|
40446
|
-
audioNode: string(),
|
|
40447
|
-
motionNode: string()
|
|
40448
|
-
}),
|
|
40449
|
-
assignedDeviceCount: number().int().min(0),
|
|
40450
|
-
cameraConfigCount: number().int().min(0),
|
|
40451
|
-
activeDetectionCount: number().int().min(0)
|
|
40452
|
-
});
|
|
40453
|
-
/**
|
|
40454
|
-
* The node-stress long-term-statistics read surface.
|
|
40455
|
-
*
|
|
40456
|
-
* A custom action rather than a cap method, matching how the orchestrator
|
|
40457
|
-
* already serves `dumpState`: this is a hub-local read over a table the hub
|
|
40458
|
-
* owns, and it ships with one `camstack deploy` instead of a release train.
|
|
40459
|
-
* The MEAN is derived here and returned alongside the addable `sum`/`samples`
|
|
40460
|
-
* — a chart wants the first, a re-bucketing caller wants the second, and a
|
|
40461
|
-
* stored mean is a field that can disagree with both.
|
|
40462
|
-
*/
|
|
40463
|
-
var NodeStressStatsInputSchema = object({
|
|
40464
|
-
/** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
|
|
40465
|
-
series: string().optional(),
|
|
40466
|
-
/** A node id. Omit for every node. */
|
|
40467
|
-
subject: string().optional(),
|
|
40468
|
-
/** Inclusive bucket-start bounds, ms. */
|
|
40469
|
-
from: number().int().optional(),
|
|
40470
|
-
to: number().int().optional(),
|
|
40471
|
-
limit: number().int().positive().max(5e3).optional()
|
|
40472
|
-
});
|
|
40473
|
-
var NodeStressStatsRowSchema = object({
|
|
40474
|
-
subject: string(),
|
|
40475
|
-
series: string(),
|
|
40476
|
-
scope: string(),
|
|
40477
|
-
bucketStart: number(),
|
|
40478
|
-
samples: number(),
|
|
40479
|
-
sum: number(),
|
|
40480
|
-
mean: number(),
|
|
40481
|
-
min: number(),
|
|
40482
|
-
max: number()
|
|
40483
|
-
});
|
|
40484
|
-
var NodeStressStatsOutputSchema = object({
|
|
40485
|
-
rows: array(NodeStressStatsRowSchema).readonly(),
|
|
40486
|
-
/** Buckets still accumulating — "is it running" answerable at once, rather
|
|
40487
|
-
* than after five minutes of indistinguishable silence. */
|
|
40488
|
-
open: array(NodeStressStatsRowSchema).readonly(),
|
|
40489
|
-
/** The durable failover history the anti-flap guards read, newest first.
|
|
40490
|
-
* Exposed for the same reason the heartbeat exists: "nothing moved" has to
|
|
40491
|
-
* be distinguishable from "nothing is watching". */
|
|
40492
|
-
moves: array(object({
|
|
40493
|
-
deviceId: number(),
|
|
40494
|
-
fromNodeId: string(),
|
|
40495
|
-
at: number()
|
|
40496
|
-
})).readonly()
|
|
40497
|
-
});
|
|
40498
|
-
var pipelineOrchestratorActions = defineCustomActions({
|
|
40499
|
-
dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
|
|
40500
|
-
nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
|
|
40501
|
-
});
|
|
40502
|
-
/**
|
|
40503
|
-
* Sentinel returned by `buildDetectionConfig` when the profile-slot READ
|
|
40504
|
-
* itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
|
|
40505
|
-
* while the stream-broker is (re)starting) — as opposed to `null`, which
|
|
40506
|
-
* means "genuinely no assigned slot / not configured". Callers MUST treat
|
|
40507
|
-
* this differently from `null`: never stop active detection on a transient
|
|
40508
|
-
* read failure (the slots almost certainly still exist), and schedule a retry.
|
|
40509
|
-
*/
|
|
40510
|
-
var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
|
|
40511
|
-
//#endregion
|
|
40512
|
-
//#region src/audio-chunk-poller.ts
|
|
40513
|
-
/**
|
|
40514
|
-
* `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
|
|
40515
|
-
* plane (Phase 5 / D9).
|
|
40516
|
-
*
|
|
40517
|
-
* Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
|
|
40518
|
-
* path. A live callback cannot cross a process boundary; once the `pipeline`
|
|
40519
|
-
* group is dissolved (Task 8) the orchestrator runs in a different process
|
|
40520
|
-
* from the broker, so audio delivery must go over tRPC.
|
|
40521
|
-
*
|
|
40522
|
-
* The consumer:
|
|
40523
|
-
*
|
|
40524
|
-
* 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
|
|
40525
|
-
* registers a per-subscription bounded FIFO queue and returns a
|
|
40526
|
-
* `subscriptionId`;
|
|
40527
|
-
* 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
|
|
40528
|
-
* interval, draining `DecodedAudioChunk`s in FIFO arrival order;
|
|
40529
|
-
* 3. feeds each chunk to its downstream audio logic;
|
|
40530
|
-
* 4. on teardown, `unsubscribeAudioChunks`.
|
|
40531
|
-
*
|
|
40532
|
-
* Audio is not latency-critical like video, and chunks arrive only ~every
|
|
40533
|
-
* 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
|
|
40534
|
-
* a small per-poll burst keeps latency low without busy-spinning. The
|
|
40535
|
-
* broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
|
|
40536
|
-
* loses a chunk.
|
|
40537
|
-
*
|
|
40538
|
-
* Boot-race tolerance: the broker for a given camStream may not be registered
|
|
40539
|
-
* yet when the orchestrator wires the subscription (provider addons publish
|
|
40540
|
-
* their cameraStreams asynchronously after their probe completes).
|
|
40541
|
-
* `subscribeAudioChunks` retries with exponential backoff (capped at
|
|
40542
|
-
* `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
|
|
40543
|
-
* teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
|
|
40544
|
-
* shape so video and audio plumbing self-heal identically.
|
|
40545
|
-
*/
|
|
40546
|
-
/** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
|
|
40547
|
-
var POLL_INTERVAL_MS$1 = 200;
|
|
40548
|
-
/** How many chunks to drain per poll — a small burst absorbs jitter. */
|
|
40549
|
-
var PULL_MAX_COUNT = 8;
|
|
40550
|
-
/**
|
|
40551
|
-
* Consecutive pull failures before we attempt to re-subscribe. A single failed
|
|
40552
|
-
* poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
|
|
40553
|
-
* sustained failure means the broker child restarted and dropped our
|
|
40554
|
-
* subscription, so we re-establish it.
|
|
40555
|
-
*/
|
|
40556
|
-
var RESUBSCRIBE_AFTER_FAILURES = 2;
|
|
40557
|
-
/** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
|
|
40558
|
-
var RESUBSCRIBE_THROTTLE_TICKS = 5;
|
|
40559
|
-
/** First subscribe-retry delay, doubled on every subsequent failure. */
|
|
40560
|
-
var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
|
|
40561
|
-
/**
|
|
40562
|
-
* Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
|
|
40563
|
-
* enough to recover within a single reconcile of the orchestrator and slow
|
|
40564
|
-
* enough that a misconfigured camStream costs ~12 op/min, not 5/s.
|
|
40565
|
-
*/
|
|
40566
|
-
var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
|
|
40567
|
-
/**
|
|
40568
|
-
* Attempts after which a still-failing subscribe escalates from the fast 5 s
|
|
40569
|
-
* ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
|
|
40570
|
-
* minute of fast retries — plenty for the boot races the 5 s ceiling exists
|
|
40571
|
-
* for. A broker that is STILL absent after that is a long-lived condition
|
|
40572
|
-
* (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
|
|
40573
|
-
* to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
|
|
40574
|
-
* churn. The slow loop stays alive so audio still recovers automatically
|
|
40575
|
-
* (≤60 s) once the camera is re-enabled.
|
|
40576
|
-
*/
|
|
40577
|
-
var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
|
|
40578
|
-
var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
|
|
40579
|
-
/**
|
|
40580
|
-
* Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
|
|
40581
|
-
*
|
|
40582
|
-
* Always resolves to a teardown closure — when the broker is not yet
|
|
40583
|
-
* registered the closure cancels the ongoing retry loop; when polling is
|
|
40584
|
-
* active it stops the loop and releases the broker subscription. Mirrors
|
|
40585
|
-
* `startFrameHandlePoller` so video and audio recover identically.
|
|
40586
|
-
*/
|
|
40587
|
-
function startAudioChunkPoller(options) {
|
|
40588
|
-
const lifecycle = {
|
|
40589
|
-
stopped: false,
|
|
40590
|
-
retryTimer: void 0,
|
|
40591
|
-
pollTimer: void 0,
|
|
40592
|
-
activeSubscriptionId: null
|
|
40593
|
-
};
|
|
40594
|
-
const teardown = () => {
|
|
40595
|
-
if (lifecycle.stopped) return;
|
|
40596
|
-
lifecycle.stopped = true;
|
|
40597
|
-
if (lifecycle.retryTimer) {
|
|
40598
|
-
clearTimeout(lifecycle.retryTimer);
|
|
40599
|
-
lifecycle.retryTimer = void 0;
|
|
40600
|
-
}
|
|
40601
|
-
if (lifecycle.pollTimer) {
|
|
40602
|
-
clearTimeout(lifecycle.pollTimer);
|
|
40603
|
-
lifecycle.pollTimer = void 0;
|
|
40604
|
-
}
|
|
40605
|
-
const subId = lifecycle.activeSubscriptionId;
|
|
40606
|
-
if (subId) {
|
|
40607
|
-
lifecycle.activeSubscriptionId = null;
|
|
40608
|
-
options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
|
|
40609
|
-
options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
|
|
40610
|
-
brokerId: options.brokerId,
|
|
40611
|
-
subscriptionId: subId,
|
|
40612
|
-
error: errMsg(err)
|
|
40613
|
-
} });
|
|
40614
|
-
});
|
|
40615
|
-
}
|
|
40616
|
-
};
|
|
40617
|
-
subscribeWithRetry(options, lifecycle);
|
|
40618
|
-
return teardown;
|
|
40217
|
+
subscribeWithRetry(options, lifecycle);
|
|
40218
|
+
return teardown;
|
|
40619
40219
|
}
|
|
40620
40220
|
/**
|
|
40621
40221
|
* Run the subscribe → poll handshake with exponential backoff on subscribe
|
|
@@ -40767,47 +40367,179 @@ function balanceAudio(input) {
|
|
|
40767
40367
|
};
|
|
40768
40368
|
}
|
|
40769
40369
|
//#endregion
|
|
40770
|
-
//#region src/
|
|
40771
|
-
var
|
|
40772
|
-
|
|
40773
|
-
|
|
40774
|
-
|
|
40775
|
-
|
|
40776
|
-
|
|
40777
|
-
|
|
40778
|
-
|
|
40779
|
-
|
|
40780
|
-
|
|
40781
|
-
|
|
40782
|
-
|
|
40783
|
-
|
|
40784
|
-
|
|
40785
|
-
|
|
40786
|
-
|
|
40787
|
-
|
|
40788
|
-
|
|
40789
|
-
|
|
40790
|
-
|
|
40791
|
-
|
|
40792
|
-
|
|
40793
|
-
|
|
40794
|
-
|
|
40795
|
-
|
|
40796
|
-
|
|
40797
|
-
|
|
40798
|
-
|
|
40799
|
-
|
|
40800
|
-
|
|
40801
|
-
|
|
40802
|
-
|
|
40803
|
-
|
|
40804
|
-
|
|
40805
|
-
|
|
40806
|
-
|
|
40807
|
-
|
|
40808
|
-
|
|
40809
|
-
|
|
40810
|
-
|
|
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
|
+
}
|
|
40379
|
+
/**
|
|
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.
|
|
40384
|
+
*/
|
|
40385
|
+
var PENDING_RETRY_INTERVAL_MS = 6e4;
|
|
40386
|
+
/** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
|
|
40387
|
+
var PENDING_RETRY_DEBOUNCE_MS = 2e3;
|
|
40388
|
+
/**
|
|
40389
|
+
* Periodic auto-rebalance sweep. New attaches are already load-balanced at
|
|
40390
|
+
* dispatch time; this corrects DRIFT that accumulates over time (uneven
|
|
40391
|
+
* detach, a node returning online, a weight change) so the steady-state
|
|
40392
|
+
* distribution tracks the per-node weights. Only runs with ≥2 enabled detect
|
|
40393
|
+
* nodes, and migrates under hysteresis so a balanced cluster is a no-op.
|
|
40394
|
+
*/
|
|
40395
|
+
var AUTO_REBALANCE_INTERVAL_MS = 6e4;
|
|
40396
|
+
/**
|
|
40397
|
+
* Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
|
|
40398
|
+
* migrate a camera only when its target node is at least this much less loaded
|
|
40399
|
+
* than its current node. > 1 so equalizing a single-camera gap (which would
|
|
40400
|
+
* only reverse the imbalance) is skipped — prevents periodic churn.
|
|
40401
|
+
*/
|
|
40402
|
+
var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
|
|
40403
|
+
var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
|
|
40404
|
+
/**
|
|
40405
|
+
* Device-details keys routed through the orchestrator's pipeline
|
|
40406
|
+
* settings writer instead of the device orchestration store. The
|
|
40407
|
+
* `cameraPipeline` key carries the full `CameraPipelineConfig`
|
|
40408
|
+
* emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
|
|
40409
|
+
*/
|
|
40410
|
+
var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
|
|
40411
|
+
var DEFAULT_FAILOVER_POLICY = {
|
|
40412
|
+
onDisconnect: "migrate",
|
|
40413
|
+
pinnedOnDisconnect: "leave-pinned",
|
|
40414
|
+
onReconnect: "restore"
|
|
40415
|
+
};
|
|
40416
|
+
/**
|
|
40417
|
+
* Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
|
|
40418
|
+
*
|
|
40419
|
+
* The orchestrator's cap surface is the contract for all runtime traffic
|
|
40420
|
+
* (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
|
|
40421
|
+
* catalog is reserved for read-only diagnostics that are intentionally
|
|
40422
|
+
* outside the cap — they expose internal state (balancer caches, enabledNodes
|
|
40423
|
+
* set, active detection count) that is useful for admin tooling but does not
|
|
40424
|
+
* belong on the capability contract.
|
|
40425
|
+
*/
|
|
40426
|
+
var OrchestratorDiagnosticsSchema = object({
|
|
40427
|
+
localNodeId: string(),
|
|
40428
|
+
knownRunnerNodes: array(string()),
|
|
40429
|
+
cachedAgentLoadNodeIds: array(string()),
|
|
40430
|
+
enabledNodes: array(string()),
|
|
40431
|
+
enabledDecoderNodes: array(string()),
|
|
40432
|
+
enabledAudioNodes: array(string()),
|
|
40433
|
+
enabledIngestNodes: array(string()),
|
|
40434
|
+
clusterRoles: object({
|
|
40435
|
+
ingestNode: string(),
|
|
40436
|
+
audioNode: string(),
|
|
40437
|
+
motionNode: string()
|
|
40438
|
+
}),
|
|
40439
|
+
assignedDeviceCount: number().int().min(0),
|
|
40440
|
+
cameraConfigCount: number().int().min(0),
|
|
40441
|
+
activeDetectionCount: number().int().min(0)
|
|
40442
|
+
});
|
|
40443
|
+
/**
|
|
40444
|
+
* The node-stress long-term-statistics read surface.
|
|
40445
|
+
*
|
|
40446
|
+
* A custom action rather than a cap method, matching how the orchestrator
|
|
40447
|
+
* already serves `dumpState`: this is a hub-local read over a table the hub
|
|
40448
|
+
* owns, and it ships with one `camstack deploy` instead of a release train.
|
|
40449
|
+
* The MEAN is derived here and returned alongside the addable `sum`/`samples`
|
|
40450
|
+
* — a chart wants the first, a re-bucketing caller wants the second, and a
|
|
40451
|
+
* stored mean is a field that can disagree with both.
|
|
40452
|
+
*/
|
|
40453
|
+
var NodeStressStatsInputSchema = object({
|
|
40454
|
+
/** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
|
|
40455
|
+
series: string().optional(),
|
|
40456
|
+
/** A node id. Omit for every node. */
|
|
40457
|
+
subject: string().optional(),
|
|
40458
|
+
/** Inclusive bucket-start bounds, ms. */
|
|
40459
|
+
from: number().int().optional(),
|
|
40460
|
+
to: number().int().optional(),
|
|
40461
|
+
limit: number().int().positive().max(5e3).optional()
|
|
40462
|
+
});
|
|
40463
|
+
var NodeStressStatsRowSchema = object({
|
|
40464
|
+
subject: string(),
|
|
40465
|
+
series: string(),
|
|
40466
|
+
scope: string(),
|
|
40467
|
+
bucketStart: number(),
|
|
40468
|
+
samples: number(),
|
|
40469
|
+
sum: number(),
|
|
40470
|
+
mean: number(),
|
|
40471
|
+
min: number(),
|
|
40472
|
+
max: number()
|
|
40473
|
+
});
|
|
40474
|
+
var NodeStressStatsOutputSchema = object({
|
|
40475
|
+
rows: array(NodeStressStatsRowSchema).readonly(),
|
|
40476
|
+
/** Buckets still accumulating — "is it running" answerable at once, rather
|
|
40477
|
+
* than after five minutes of indistinguishable silence. */
|
|
40478
|
+
open: array(NodeStressStatsRowSchema).readonly(),
|
|
40479
|
+
/** The durable failover history the anti-flap guards read, newest first.
|
|
40480
|
+
* Exposed for the same reason the heartbeat exists: "nothing moved" has to
|
|
40481
|
+
* be distinguishable from "nothing is watching". */
|
|
40482
|
+
moves: array(object({
|
|
40483
|
+
deviceId: number(),
|
|
40484
|
+
fromNodeId: string(),
|
|
40485
|
+
at: number()
|
|
40486
|
+
})).readonly()
|
|
40487
|
+
});
|
|
40488
|
+
var pipelineOrchestratorActions = defineCustomActions({
|
|
40489
|
+
dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
|
|
40490
|
+
nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
|
|
40491
|
+
});
|
|
40492
|
+
/**
|
|
40493
|
+
* Sentinel returned by `buildDetectionConfig` when the profile-slot READ
|
|
40494
|
+
* itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
|
|
40495
|
+
* while the stream-broker is (re)starting) — as opposed to `null`, which
|
|
40496
|
+
* means "genuinely no assigned slot / not configured". Callers MUST treat
|
|
40497
|
+
* this differently from `null`: never stop active detection on a transient
|
|
40498
|
+
* read failure (the slots almost certainly still exist), and schedule a retry.
|
|
40499
|
+
*/
|
|
40500
|
+
var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
|
|
40501
|
+
//#endregion
|
|
40502
|
+
//#region src/audio-window-accumulator.ts
|
|
40503
|
+
var AudioWindowAccumulator = class {
|
|
40504
|
+
deviceId;
|
|
40505
|
+
pcmParts = [];
|
|
40506
|
+
accumulatedBytes = 0;
|
|
40507
|
+
accumulatedMs = 0;
|
|
40508
|
+
windowSampleRate = 0;
|
|
40509
|
+
windowChannels = 0;
|
|
40510
|
+
windowTimestamp = 0;
|
|
40511
|
+
windowOpen = false;
|
|
40512
|
+
constructor(deviceId) {
|
|
40513
|
+
this.deviceId = deviceId;
|
|
40514
|
+
}
|
|
40515
|
+
/**
|
|
40516
|
+
* Append one decoded PCM chunk to the open window. Returns the flushed
|
|
40517
|
+
* `AudioChunkInput` once the accumulated duration reaches
|
|
40518
|
+
* `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
|
|
40519
|
+
* (accumulate-only, no flush yet).
|
|
40520
|
+
*/
|
|
40521
|
+
push(chunk) {
|
|
40522
|
+
const byteLength = chunk.data.byteLength;
|
|
40523
|
+
const bytes = new Uint8Array(byteLength);
|
|
40524
|
+
bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
|
|
40525
|
+
if (!this.windowOpen) {
|
|
40526
|
+
this.windowSampleRate = chunk.sampleRate;
|
|
40527
|
+
this.windowChannels = chunk.channels;
|
|
40528
|
+
this.windowTimestamp = chunk.timestamp;
|
|
40529
|
+
this.windowOpen = true;
|
|
40530
|
+
}
|
|
40531
|
+
this.pcmParts.push(bytes);
|
|
40532
|
+
this.accumulatedBytes += byteLength;
|
|
40533
|
+
const channels = chunk.channels > 0 ? chunk.channels : 1;
|
|
40534
|
+
const framesPerChannel = byteLength / 4 / channels;
|
|
40535
|
+
this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
|
|
40536
|
+
if (this.accumulatedMs < 1e3) return null;
|
|
40537
|
+
const windowData = new Uint8Array(this.accumulatedBytes);
|
|
40538
|
+
let offset = 0;
|
|
40539
|
+
for (const part of this.pcmParts) {
|
|
40540
|
+
windowData.set(part, offset);
|
|
40541
|
+
offset += part.byteLength;
|
|
40542
|
+
}
|
|
40811
40543
|
const flushSampleRate = this.windowSampleRate;
|
|
40812
40544
|
const flushChannels = this.windowChannels;
|
|
40813
40545
|
const flushTimestamp = this.windowTimestamp;
|
|
@@ -41270,234 +41002,847 @@ var AudioSubscriptionController = class {
|
|
|
41270
41002
|
meta: { error: errMsg(err) }
|
|
41271
41003
|
});
|
|
41272
41004
|
});
|
|
41273
|
-
}, windowMs);
|
|
41274
|
-
this.motionAudioWindowTimers.set(deviceId, timer);
|
|
41275
|
-
}
|
|
41276
|
-
/** True while a motion-driven audio window is open for this device. */
|
|
41277
|
-
isMotionAudioWindowOpen(deviceId) {
|
|
41278
|
-
return this.motionAudioWindowTimers.has(deviceId);
|
|
41279
|
-
}
|
|
41280
|
-
/**
|
|
41281
|
-
* Close an on-motion audio window and drop its subscription. Shared by both
|
|
41282
|
-
* closers (quiet window elapsed / falling edge cooldown) so they can never
|
|
41283
|
-
* disagree about what "closed" means. `reason` is logged so a camera that
|
|
41284
|
-
* loses audio can always be told WHY from the per-device log view.
|
|
41285
|
-
*/
|
|
41286
|
-
async closeMotionAudioWindow(deviceId, reason) {
|
|
41287
|
-
const pendingWindow = this.motionAudioWindowTimers.get(deviceId);
|
|
41288
|
-
if (pendingWindow) {
|
|
41289
|
-
clearTimeout(pendingWindow);
|
|
41290
|
-
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;
|
|
41291
41583
|
}
|
|
41292
|
-
|
|
41293
|
-
|
|
41294
|
-
|
|
41295
|
-
|
|
41296
|
-
|
|
41297
|
-
} catch {}
|
|
41298
|
-
this.audioSubscriptions.delete(deviceId);
|
|
41299
|
-
this.deps.logger.info("lazy audio: window closed", {
|
|
41300
|
-
tags: { deviceId },
|
|
41301
|
-
meta: { reason }
|
|
41584
|
+
if (isPoolUsable && !isPoolUsable(d.key)) {
|
|
41585
|
+
excluded.push({
|
|
41586
|
+
key: d.key,
|
|
41587
|
+
reason: "unavailable",
|
|
41588
|
+
format: d.format
|
|
41302
41589
|
});
|
|
41303
|
-
|
|
41304
|
-
}
|
|
41305
|
-
/**
|
|
41306
|
-
* Audio teardown for one device — the audio half of `stopDetection`.
|
|
41307
|
-
* Tears down through the per-device lock so it can't race a concurrent
|
|
41308
|
-
* subscribe (which would re-store a handle this teardown never sees).
|
|
41309
|
-
*/
|
|
41310
|
-
async stopForDevice(deviceId) {
|
|
41311
|
-
await this.withAudioSubLock(deviceId, async () => {
|
|
41312
|
-
const unsub = this.audioSubscriptions.get(deviceId);
|
|
41313
|
-
if (unsub) {
|
|
41314
|
-
try {
|
|
41315
|
-
unsub();
|
|
41316
|
-
} catch {}
|
|
41317
|
-
this.audioSubscriptions.delete(deviceId);
|
|
41318
|
-
}
|
|
41319
|
-
});
|
|
41320
|
-
const lazyTimer = this.lazyAudioTeardownTimers.get(deviceId);
|
|
41321
|
-
if (lazyTimer) {
|
|
41322
|
-
clearTimeout(lazyTimer);
|
|
41323
|
-
this.lazyAudioTeardownTimers.delete(deviceId);
|
|
41590
|
+
continue;
|
|
41324
41591
|
}
|
|
41325
|
-
|
|
41326
|
-
|
|
41327
|
-
|
|
41328
|
-
|
|
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;
|
|
41329
41599
|
}
|
|
41330
|
-
|
|
41331
|
-
|
|
41332
|
-
|
|
41333
|
-
|
|
41334
|
-
|
|
41335
|
-
|
|
41336
|
-
|
|
41337
|
-
* (best-effort, error-swallowed) and DO NOT store. `protected` so
|
|
41338
|
-
* `audio-sub-lock.spec.ts`'s test subclass can assert the shutdown-guard
|
|
41339
|
-
* behavior without casts.
|
|
41340
|
-
*/
|
|
41341
|
-
storeAudioSub(deviceId, unsub) {
|
|
41342
|
-
if (this.audioShuttingDown) {
|
|
41343
|
-
try {
|
|
41344
|
-
unsub();
|
|
41345
|
-
} catch {}
|
|
41346
|
-
return;
|
|
41600
|
+
if (isCpuFallback(d) && acceleratorServes) {
|
|
41601
|
+
excluded.push({
|
|
41602
|
+
key: d.key,
|
|
41603
|
+
reason: "accelerator-preferred",
|
|
41604
|
+
format: d.format
|
|
41605
|
+
});
|
|
41606
|
+
continue;
|
|
41347
41607
|
}
|
|
41348
|
-
|
|
41349
|
-
}
|
|
41350
|
-
/**
|
|
41351
|
-
* Serialize an audio-subscription critical section per device. `fn` is
|
|
41352
|
-
* chained onto the device's current lock tail, so concurrent calls for the
|
|
41353
|
-
* SAME deviceId run sequentially (FIFO); different deviceIds never block
|
|
41354
|
-
* each other. Thin delegate onto the `audioSubLocks` `KeyedAsyncLock`
|
|
41355
|
-
* instance. `protected` so `audio-sub-lock.spec.ts`'s test subclass can
|
|
41356
|
-
* drive the lock without casts.
|
|
41357
|
-
*/
|
|
41358
|
-
withAudioSubLock(deviceId, fn) {
|
|
41359
|
-
return this.audioSubLocks.run(deviceId, fn);
|
|
41608
|
+
eligible[d.key] = d.weight;
|
|
41360
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();
|
|
41361
41665
|
/**
|
|
41362
|
-
*
|
|
41363
|
-
*
|
|
41364
|
-
* `resolveDeviceSettings(deviceId)` method so the orchestrator does not
|
|
41365
|
-
* touch the audio-analyzer schema field names directly.
|
|
41666
|
+
* Fold one observation in and report whether the caller should act.
|
|
41667
|
+
* Never throws.
|
|
41366
41668
|
*/
|
|
41367
|
-
|
|
41368
|
-
const
|
|
41369
|
-
if (
|
|
41370
|
-
this.
|
|
41371
|
-
|
|
41372
|
-
|
|
41373
|
-
|
|
41374
|
-
|
|
41375
|
-
this.deps.logger.debug("audio subscribe skipped: audioMode=disabled", { tags: { deviceId } });
|
|
41376
|
-
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;
|
|
41377
41677
|
}
|
|
41378
|
-
if (
|
|
41379
|
-
this.
|
|
41678
|
+
if (prev === void 0) {
|
|
41679
|
+
this.state.set(nodeId, {
|
|
41680
|
+
usable: true,
|
|
41681
|
+
armed: true
|
|
41682
|
+
});
|
|
41380
41683
|
return null;
|
|
41381
41684
|
}
|
|
41382
|
-
|
|
41383
|
-
|
|
41384
|
-
|
|
41385
|
-
|
|
41386
|
-
tags: { deviceId },
|
|
41387
|
-
meta: {
|
|
41388
|
-
camStreamId: audioStream,
|
|
41389
|
-
brokerId: audioBrokerId,
|
|
41390
|
-
selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
|
|
41391
|
-
hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
|
|
41392
|
-
}
|
|
41685
|
+
if (!prev.usable) {
|
|
41686
|
+
this.state.set(nodeId, {
|
|
41687
|
+
usable: false,
|
|
41688
|
+
armed: true
|
|
41393
41689
|
});
|
|
41394
41690
|
return null;
|
|
41395
41691
|
}
|
|
41396
|
-
|
|
41397
|
-
|
|
41398
|
-
|
|
41692
|
+
if (!prev.armed) {
|
|
41693
|
+
this.state.set(nodeId, {
|
|
41694
|
+
usable: true,
|
|
41695
|
+
armed: true
|
|
41696
|
+
});
|
|
41399
41697
|
return null;
|
|
41400
41698
|
}
|
|
41401
|
-
|
|
41402
|
-
|
|
41403
|
-
|
|
41404
|
-
tags: { deviceId },
|
|
41405
|
-
meta: {
|
|
41406
|
-
audioNodeId,
|
|
41407
|
-
isRemote: isRemoteAudio
|
|
41408
|
-
}
|
|
41409
|
-
});
|
|
41410
|
-
const accumulator = new AudioWindowAccumulator(deviceId);
|
|
41411
|
-
const teardown = startAudioChunkPoller({
|
|
41412
|
-
api,
|
|
41413
|
-
brokerId: audioBrokerId,
|
|
41414
|
-
tag: "audio-analyzer",
|
|
41415
|
-
ownerNodeId: this.deps.ingestNode(),
|
|
41416
|
-
logger: this.deps.logger.withTags({ deviceId }),
|
|
41417
|
-
onChunk: async (chunk) => {
|
|
41418
|
-
this.deps.watchdogNote(deviceId, "audio");
|
|
41419
|
-
try {
|
|
41420
|
-
const audioChunkInput = accumulator.push(chunk);
|
|
41421
|
-
if (!audioChunkInput) return;
|
|
41422
|
-
const result = await api.audioAnalyzer.analyseChunk.mutate({
|
|
41423
|
-
chunk: audioChunkInput,
|
|
41424
|
-
settings,
|
|
41425
|
-
...isRemoteAudio ? { nodeId: audioNodeId } : {}
|
|
41426
|
-
});
|
|
41427
|
-
if (!result) return;
|
|
41428
|
-
const frame = buildAudioResultFrame(deviceId, result);
|
|
41429
|
-
this.deps.eventBus.emit({
|
|
41430
|
-
id: `audio-inference-${deviceId}-${Date.now()}`,
|
|
41431
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
41432
|
-
source: {
|
|
41433
|
-
type: "device",
|
|
41434
|
-
id: deviceId,
|
|
41435
|
-
nodeId: "hub",
|
|
41436
|
-
addonId: "pipeline-orchestrator",
|
|
41437
|
-
deviceId
|
|
41438
|
-
},
|
|
41439
|
-
category: EventCategory.PipelineAudioInferenceResult,
|
|
41440
|
-
data: {
|
|
41441
|
-
deviceId,
|
|
41442
|
-
frame,
|
|
41443
|
-
nodeId: "hub"
|
|
41444
|
-
}
|
|
41445
|
-
});
|
|
41446
|
-
} catch (err) {
|
|
41447
|
-
const msg = errMsg(err);
|
|
41448
|
-
this.deps.logger.error("Audio analysis failed", {
|
|
41449
|
-
tags: { deviceId },
|
|
41450
|
-
meta: { error: msg }
|
|
41451
|
-
});
|
|
41452
|
-
}
|
|
41453
|
-
}
|
|
41699
|
+
this.state.set(nodeId, {
|
|
41700
|
+
usable: false,
|
|
41701
|
+
armed: true
|
|
41454
41702
|
});
|
|
41455
|
-
|
|
41456
|
-
return () => {
|
|
41457
|
-
teardown();
|
|
41458
|
-
accumulator.reset();
|
|
41459
|
-
};
|
|
41703
|
+
return "became-unusable";
|
|
41460
41704
|
}
|
|
41461
|
-
/**
|
|
41462
|
-
|
|
41463
|
-
|
|
41464
|
-
|
|
41465
|
-
|
|
41466
|
-
|
|
41467
|
-
|
|
41468
|
-
|
|
41469
|
-
|
|
41470
|
-
|
|
41471
|
-
|
|
41472
|
-
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);
|
|
41473
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();
|
|
41474
41776
|
/**
|
|
41475
|
-
*
|
|
41476
|
-
*
|
|
41477
|
-
* session/reconcile/load-shed clears — subscription teardown + lock clear
|
|
41478
|
-
* + assignment-map clears) into one call. Safe to combine: both blocks
|
|
41479
|
-
* are synchronous with no interleaved `await`, and every original
|
|
41480
|
-
* statement between them (`sessionRegistry.clear()`,
|
|
41481
|
-
* `cameraFpsMap.clear()`, `remoteHealthAttempts.clear()`,
|
|
41482
|
-
* `loadShedState.clear()`, `loadShedResumeTimer` cleanup) touches state
|
|
41483
|
-
* fully disjoint from anything audio — so their relative order to each
|
|
41484
|
-
* other is unaffected, and the audio-internal order (timers →
|
|
41485
|
-
* subscriptions → lock → assignment maps) is reproduced exactly.
|
|
41777
|
+
* Fold one observation in and report whether the caller should act.
|
|
41778
|
+
* Never throws.
|
|
41486
41779
|
*/
|
|
41487
|
-
|
|
41488
|
-
|
|
41489
|
-
|
|
41490
|
-
|
|
41491
|
-
|
|
41492
|
-
|
|
41493
|
-
|
|
41494
|
-
|
|
41495
|
-
|
|
41496
|
-
this.
|
|
41497
|
-
|
|
41498
|
-
|
|
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();
|
|
41499
41806
|
}
|
|
41500
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
|
+
}
|
|
41501
41846
|
//#endregion
|
|
41502
41847
|
//#region src/load-balancer.ts
|
|
41503
41848
|
/**
|
|
@@ -43433,6 +43778,90 @@ function applyDeviceProvisioning(steps, base, override) {
|
|
|
43433
43778
|
});
|
|
43434
43779
|
}
|
|
43435
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
|
|
43436
43865
|
//#region src/device-detection-settings.ts
|
|
43437
43866
|
/** Read a required string leaf out of the hydrated `flat` schema values. */
|
|
43438
43867
|
function mustString(flat, deviceId, key) {
|
|
@@ -43458,6 +43887,32 @@ function numberOrDefault(flat, key) {
|
|
|
43458
43887
|
const dflt = uiField && "default" in uiField ? uiField.default : void 0;
|
|
43459
43888
|
return typeof dflt === "number" ? dflt : 0;
|
|
43460
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
|
+
}
|
|
43461
43916
|
/**
|
|
43462
43917
|
* Pure decision/derivation half of the former `resolveDeviceDetectionSettings`.
|
|
43463
43918
|
* Given the I/O-gathered raw materials, resolves every operator-wins →
|
|
@@ -43467,14 +43922,19 @@ function numberOrDefault(flat, key) {
|
|
|
43467
43922
|
* the original method's "narrowing failed" catch.
|
|
43468
43923
|
*/
|
|
43469
43924
|
function resolveDetectionSettings(input) {
|
|
43470
|
-
const { deviceId, raw, flat, features, hasOnboardMotion, pipelineEnabled, motionDetectionEnabled } = input;
|
|
43925
|
+
const { deviceId, raw, flat, features, hasOnboardMotion, hasActivitySignal, pipelineEnabled, motionDetectionEnabled } = input;
|
|
43471
43926
|
const profile = resolveDeviceProfile(features);
|
|
43472
43927
|
const userMotionSources = raw["motionSources"];
|
|
43473
43928
|
let motionSources;
|
|
43474
43929
|
if (userMotionSources !== void 0) motionSources = MotionSourcesSchema.parse(userMotionSources);
|
|
43475
|
-
else
|
|
43476
|
-
|
|
43477
|
-
|
|
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
|
+
}
|
|
43478
43938
|
const userDetectionMode = raw["detectionMode"];
|
|
43479
43939
|
const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
|
|
43480
43940
|
const userAudioMode = raw["audioMode"];
|
|
@@ -43493,6 +43953,7 @@ function resolveDetectionSettings(input) {
|
|
|
43493
43953
|
detectionStreamProfile: mustString(flat, deviceId, "detectionStreamProfile"),
|
|
43494
43954
|
motionFps: numberOrDefault(flat, "motionFps"),
|
|
43495
43955
|
detectionFps: numberOrDefault(flat, "detectionFps"),
|
|
43956
|
+
activityDetectionFps: activityFps(flat),
|
|
43496
43957
|
motionCooldownMs: numberOrDefault(flat, "motionCooldownMs"),
|
|
43497
43958
|
maxSessionHoldMs: numberOrDefault(flat, "maxSessionHoldMs"),
|
|
43498
43959
|
audioMotionWindowMs: numberOrDefault(flat, "audioMotionWindowMs"),
|
|
@@ -43549,6 +44010,7 @@ function buildDetectionConfigFromInputs(resolved, assigned) {
|
|
|
43549
44010
|
detectionStreamId: detectionCamStreamId,
|
|
43550
44011
|
motionFps: resolved.motionFps,
|
|
43551
44012
|
detectionFps: resolved.detectionFps,
|
|
44013
|
+
activityDetectionFps: resolved.activityDetectionFps,
|
|
43552
44014
|
motionCooldownMs: resolved.motionCooldownMs,
|
|
43553
44015
|
maxSessionHoldMs: resolved.maxSessionHoldMs,
|
|
43554
44016
|
audioMotionWindowMs: resolved.audioMotionWindowMs,
|
|
@@ -43574,6 +44036,7 @@ function detectionConfigEquals(a, b) {
|
|
|
43574
44036
|
if (a.detectionStreamId !== b.detectionStreamId) return false;
|
|
43575
44037
|
if (a.motionFps !== b.motionFps) return false;
|
|
43576
44038
|
if (a.detectionFps !== b.detectionFps) return false;
|
|
44039
|
+
if (a.activityDetectionFps !== b.activityDetectionFps) return false;
|
|
43577
44040
|
if (a.motionCooldownMs !== b.motionCooldownMs) return false;
|
|
43578
44041
|
if (a.maxSessionHoldMs !== b.maxSessionHoldMs) return false;
|
|
43579
44042
|
if (a.audioMotionWindowMs !== b.audioMotionWindowMs) return false;
|
|
@@ -44200,6 +44663,7 @@ var DetectionWiringController = class {
|
|
|
44200
44663
|
try {
|
|
44201
44664
|
const features = await this.lookupDeviceFeatures(deviceId);
|
|
44202
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;
|
|
44203
44667
|
const pipelineEnabled = await this.isDetectionPipelineActive(deviceId);
|
|
44204
44668
|
const motionDetectionEnabled = await this.isMotionDetectionActive(deviceId);
|
|
44205
44669
|
return resolveDetectionSettings({
|
|
@@ -44208,6 +44672,7 @@ var DetectionWiringController = class {
|
|
|
44208
44672
|
flat,
|
|
44209
44673
|
features,
|
|
44210
44674
|
hasOnboardMotion,
|
|
44675
|
+
hasActivitySignal,
|
|
44211
44676
|
pipelineEnabled,
|
|
44212
44677
|
motionDetectionEnabled
|
|
44213
44678
|
});
|
|
@@ -44813,9 +45278,10 @@ var DeviceConfigContributions = class {
|
|
|
44813
45278
|
const schema = this.deps.deviceSettingsSchema();
|
|
44814
45279
|
if (!schema) return null;
|
|
44815
45280
|
const hasOnboardMotion = await this.deps.deviceHasOnboardMotionCap(input.deviceId);
|
|
44816
|
-
const
|
|
45281
|
+
const hasActivitySignal = await this.deps.deviceHasActivitySignalCap(input.deviceId);
|
|
45282
|
+
const rawWithDefaults = raw["motionSources"] === void 0 && (hasOnboardMotion || hasActivitySignal) ? {
|
|
44817
45283
|
...raw,
|
|
44818
|
-
motionSources: ["onboard"]
|
|
45284
|
+
motionSources: [...hasOnboardMotion ? ["onboard"] : [], ...hasActivitySignal ? ["device-activity"] : []]
|
|
44819
45285
|
} : raw;
|
|
44820
45286
|
const baseSections = hydrateSchema({
|
|
44821
45287
|
...schema,
|
|
@@ -47635,7 +48101,8 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47635
48101
|
if (!isEvent(event, EventCategory.MotionOnMotionChanged)) return;
|
|
47636
48102
|
const { deviceId, detected, timestamp } = event.data;
|
|
47637
48103
|
if (typeof deviceId !== "number") return;
|
|
47638
|
-
|
|
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) => {
|
|
47639
48106
|
deps.logger.warn("session motion handler failed", {
|
|
47640
48107
|
tags: { deviceId },
|
|
47641
48108
|
meta: {
|
|
@@ -47657,8 +48124,47 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47657
48124
|
const deviceId = event.source.deviceId;
|
|
47658
48125
|
if (typeof deviceId === "number") deps.noteWatchdogSignal(deviceId, "motion");
|
|
47659
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
|
+
});
|
|
47660
48165
|
return () => {
|
|
47661
48166
|
isTornDown = true;
|
|
48167
|
+
activitySource.stop();
|
|
47662
48168
|
for (const t of profileSlotTimers.values()) clearTimeout(t);
|
|
47663
48169
|
profileSlotTimers.clear();
|
|
47664
48170
|
unsubDeviceRegistered();
|
|
@@ -47673,6 +48179,7 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47673
48179
|
unsubSessionMotion();
|
|
47674
48180
|
unsubFrameTracked();
|
|
47675
48181
|
unsubMotionAnalysis();
|
|
48182
|
+
unsubDeviceActivity();
|
|
47676
48183
|
};
|
|
47677
48184
|
}
|
|
47678
48185
|
//#endregion
|
|
@@ -50432,7 +50939,7 @@ var SessionDispatchController = class {
|
|
|
50432
50939
|
* standing attach, so `hasStandingAttach` stays true for them and
|
|
50433
50940
|
* `decideSessionAction` still returns `'ignore'`.
|
|
50434
50941
|
*/
|
|
50435
|
-
async handleSessionMotion(deviceId, detected, emittedAt) {
|
|
50942
|
+
async handleSessionMotion(deviceId, detected, emittedAt, trigger) {
|
|
50436
50943
|
const receivedAt = Date.now();
|
|
50437
50944
|
const busLagMs = emittedAt !== void 0 ? receivedAt - emittedAt : void 0;
|
|
50438
50945
|
const config = this.deps.getActiveDetectionConfig(deviceId);
|
|
@@ -50467,7 +50974,7 @@ var SessionDispatchController = class {
|
|
|
50467
50974
|
return;
|
|
50468
50975
|
}
|
|
50469
50976
|
this.activeRefireCountByDevice.delete(deviceId);
|
|
50470
|
-
await this.dispatchDetectionSession(deviceId, cur);
|
|
50977
|
+
await this.dispatchDetectionSession(deviceId, cur, trigger);
|
|
50471
50978
|
if (this.sessionRegistry.has(deviceId)) this.scheduleSessionTeardown(deviceId, cooldownMs);
|
|
50472
50979
|
const doneAt = Date.now();
|
|
50473
50980
|
this.deps.logger.info("session motion → attach latency", {
|
|
@@ -50506,7 +51013,7 @@ var SessionDispatchController = class {
|
|
|
50506
51013
|
* `dispatchCamera`) avoids any risk of changing that already-live
|
|
50507
51014
|
* standing-camera path.
|
|
50508
51015
|
*/
|
|
50509
|
-
async dispatchDetectionSession(deviceId, config) {
|
|
51016
|
+
async dispatchDetectionSession(deviceId, config, trigger) {
|
|
50510
51017
|
const log = this.deps.logger.withTags({ deviceId });
|
|
50511
51018
|
await this.deps.reconcilePlacementFromRunners();
|
|
50512
51019
|
const preferredAgent = await this.deps.readPipelinePin(deviceId);
|
|
@@ -50562,13 +51069,19 @@ var SessionDispatchController = class {
|
|
|
50562
51069
|
const steps = applyDeviceProvisioning(pipelineConfig.steps, deviceBase, deviceOverride);
|
|
50563
51070
|
const inferenceDevices = deviceKey && Object.keys(enabledDevices).length >= 2 ? buildInferenceDeviceRoster(enabledDevices, deviceCaps) : void 0;
|
|
50564
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
|
+
} });
|
|
50565
51078
|
const sessionConfig = {
|
|
50566
51079
|
deviceId,
|
|
50567
51080
|
...deviceKey ? { deviceKey } : {},
|
|
50568
51081
|
...inferenceDevices ? { inferenceDevices } : {},
|
|
50569
51082
|
motionCooldownMs: config.motionCooldownMs,
|
|
50570
51083
|
motionFps: config.motionFps,
|
|
50571
|
-
detectionFps:
|
|
51084
|
+
detectionFps: sessionDetectionFps,
|
|
50572
51085
|
motionStreamId: config.motionStreamId,
|
|
50573
51086
|
detectionStreamId: config.detectionStreamId,
|
|
50574
51087
|
motionSources: [],
|
|
@@ -51873,6 +52386,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
51873
52386
|
localNodeId: () => localNodeId,
|
|
51874
52387
|
deviceSettingsSchema: () => deps.deviceSettingsSchema(),
|
|
51875
52388
|
deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
|
|
52389
|
+
deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
|
|
51876
52390
|
deviceHasNativeObjectDetectionCap: (deviceId) => deps.deviceHasNativeObjectDetectionCap(deviceId),
|
|
51877
52391
|
setCameraPipelineForAgent: (input) => deps.setCameraPipelineForAgent(input),
|
|
51878
52392
|
emitCameraUpdated: (deviceId, config) => deps.emitCameraUpdated(deviceId, config),
|
|
@@ -52190,6 +52704,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
52190
52704
|
},
|
|
52191
52705
|
isCapActiveForDevice: (deviceId, capName) => deps.isCapActiveForDevice(deviceId, capName),
|
|
52192
52706
|
deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
|
|
52707
|
+
deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
|
|
52193
52708
|
deviceSettingsSchema: () => deps.deviceSettingsSchema()
|
|
52194
52709
|
});
|
|
52195
52710
|
await detectionWiring.hydrateFeaturesMirror().catch((err) => {
|
|
@@ -52225,255 +52740,6 @@ async function buildOrchestratorControllers(deps) {
|
|
|
52225
52740
|
};
|
|
52226
52741
|
}
|
|
52227
52742
|
//#endregion
|
|
52228
|
-
//#region src/viewer-ui-provider.ts
|
|
52229
|
-
/**
|
|
52230
|
-
* viewer-ui provider — the pipeline-orchestrator serves the CamStack viewer web
|
|
52231
|
-
* SPA (mirrors what the now-removed standalone addon-viewer-ui used to do).
|
|
52232
|
-
*
|
|
52233
|
-
* Why here: the orchestrator is a hub bootstrap addon (always installed + baked),
|
|
52234
|
-
* so folding the viewer serving into it avoids a second addon whose only job was
|
|
52235
|
-
* to hold static files. The viewer's Expo web export is COPIED into this addon's
|
|
52236
|
-
* `assets/viewer/` locally by the viewer repo's `scripts/copy-dist-to-orchestrator.js`
|
|
52237
|
-
* (run from a checkout that HAS the `camstack/` submodule + Expo toolchain — the
|
|
52238
|
-
* publish/image CI does not, which is exactly why we copy a pre-built dist rather
|
|
52239
|
-
* than build it here). vite emits only into `dist/`, so `assets/viewer` survives
|
|
52240
|
-
* the addon build; `assets` is in the package `files`, so it ships on
|
|
52241
|
-
* `camstack deploy`. The hub's `main.ts` resolves the `viewer-ui` singleton at
|
|
52242
|
-
* boot and mounts the SPA at `/viewer/camstack`.
|
|
52243
|
-
*
|
|
52244
|
-
* `index.js` runs from `dist/`, so the SPA root is one level up + `assets/viewer`.
|
|
52245
|
-
*/
|
|
52246
|
-
var __dirname$1 = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
52247
|
-
/** Absolute path to the staged viewer web SPA (`<addon-root>/assets/viewer`). */
|
|
52248
|
-
function resolveViewerDistDir() {
|
|
52249
|
-
return node_path.default.resolve(__dirname$1, "..", "assets", "viewer");
|
|
52250
|
-
}
|
|
52251
|
-
/** Version of the staged viewer, written by the copy script; 'unknown' if absent. */
|
|
52252
|
-
function readViewerVersion() {
|
|
52253
|
-
try {
|
|
52254
|
-
const raw = node_fs.default.readFileSync(node_path.default.join(resolveViewerDistDir(), ".viewer-version"), "utf-8").trim();
|
|
52255
|
-
if (raw) return raw;
|
|
52256
|
-
} catch {}
|
|
52257
|
-
return "unknown";
|
|
52258
|
-
}
|
|
52259
|
-
/** Build the viewer-ui provider. Serves whatever dist was staged into
|
|
52260
|
-
* `assets/viewer`; when nothing is staged the hub's mount cleanly 404s. */
|
|
52261
|
-
function createViewerUiProvider() {
|
|
52262
|
-
return {
|
|
52263
|
-
getStaticDir: async () => ({ staticDir: resolveViewerDistDir() }),
|
|
52264
|
-
getVersion: async () => ({ version: readViewerVersion() })
|
|
52265
|
-
};
|
|
52266
|
-
}
|
|
52267
|
-
//#endregion
|
|
52268
|
-
//#region src/disk-reconcile-fleet.ts
|
|
52269
|
-
async function reconcileFleetFromDisk(deps) {
|
|
52270
|
-
const deviceIds = await deps.listDeviceIds();
|
|
52271
|
-
const failed = [];
|
|
52272
|
-
let cameras = 0;
|
|
52273
|
-
let mediaDropped = 0;
|
|
52274
|
-
let tracks = 0;
|
|
52275
|
-
let events = 0;
|
|
52276
|
-
let completed = 0;
|
|
52277
|
-
for (const deviceId of deviceIds) {
|
|
52278
|
-
try {
|
|
52279
|
-
await deps.rescanRecordings(deviceId);
|
|
52280
|
-
const counts = await deps.reconcileAnalytics(deviceId);
|
|
52281
|
-
cameras += 1;
|
|
52282
|
-
mediaDropped += counts.mediaDropped;
|
|
52283
|
-
tracks += counts.tracks;
|
|
52284
|
-
events += counts.events;
|
|
52285
|
-
} catch {
|
|
52286
|
-
failed.push(deviceId);
|
|
52287
|
-
}
|
|
52288
|
-
completed += 1;
|
|
52289
|
-
deps.onProgress?.({
|
|
52290
|
-
deviceId,
|
|
52291
|
-
total: deviceIds.length,
|
|
52292
|
-
completed,
|
|
52293
|
-
failed: [...failed],
|
|
52294
|
-
mediaDropped,
|
|
52295
|
-
tracks,
|
|
52296
|
-
events
|
|
52297
|
-
});
|
|
52298
|
-
}
|
|
52299
|
-
return {
|
|
52300
|
-
cameras,
|
|
52301
|
-
failed,
|
|
52302
|
-
mediaDropped,
|
|
52303
|
-
tracks,
|
|
52304
|
-
events
|
|
52305
|
-
};
|
|
52306
|
-
}
|
|
52307
|
-
//#endregion
|
|
52308
|
-
//#region src/disk-reconcile-job.ts
|
|
52309
|
-
/**
|
|
52310
|
-
* In-memory disk-wins fleet job. The tRPC mutation starts this and returns
|
|
52311
|
-
* immediately; the walk runs in the addon process so a 60s UDS timeout cannot
|
|
52312
|
-
* abort it. Status is polled via getReconcileFromDiskStatus.
|
|
52313
|
-
*/
|
|
52314
|
-
function idleDiskReconcileJob() {
|
|
52315
|
-
return {
|
|
52316
|
-
state: "idle",
|
|
52317
|
-
total: 0,
|
|
52318
|
-
completed: 0,
|
|
52319
|
-
currentDeviceId: null,
|
|
52320
|
-
failed: [],
|
|
52321
|
-
mediaDropped: 0,
|
|
52322
|
-
tracks: 0,
|
|
52323
|
-
events: 0,
|
|
52324
|
-
startedAtMs: null,
|
|
52325
|
-
finishedAtMs: null,
|
|
52326
|
-
error: null
|
|
52327
|
-
};
|
|
52328
|
-
}
|
|
52329
|
-
function isTimeoutError(err) {
|
|
52330
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
52331
|
-
return /timed out/i.test(message);
|
|
52332
|
-
}
|
|
52333
|
-
async function withTimeoutRetry(run) {
|
|
52334
|
-
try {
|
|
52335
|
-
return await run();
|
|
52336
|
-
} catch (err) {
|
|
52337
|
-
if (!isTimeoutError(err)) throw err;
|
|
52338
|
-
return await run();
|
|
52339
|
-
}
|
|
52340
|
-
}
|
|
52341
|
-
function createDiskReconcileJobRunner(now = Date.now) {
|
|
52342
|
-
let job = idleDiskReconcileJob();
|
|
52343
|
-
let inFlight = null;
|
|
52344
|
-
const snapshot = () => job;
|
|
52345
|
-
const start = (deps) => {
|
|
52346
|
-
if (job.state === "running" && inFlight) return job;
|
|
52347
|
-
job = {
|
|
52348
|
-
...idleDiskReconcileJob(),
|
|
52349
|
-
state: "running",
|
|
52350
|
-
startedAtMs: now()
|
|
52351
|
-
};
|
|
52352
|
-
deps.log?.("pipeline disk reconcile started");
|
|
52353
|
-
inFlight = (async () => {
|
|
52354
|
-
try {
|
|
52355
|
-
const result = await reconcileFleetFromDisk({
|
|
52356
|
-
listDeviceIds: deps.listDeviceIds,
|
|
52357
|
-
rescanRecordings: (deviceId) => withTimeoutRetry(() => deps.rescanRecordings(deviceId)),
|
|
52358
|
-
reconcileAnalytics: (deviceId) => withTimeoutRetry(() => deps.reconcileAnalytics(deviceId)),
|
|
52359
|
-
onProgress: (update) => {
|
|
52360
|
-
job = {
|
|
52361
|
-
...job,
|
|
52362
|
-
total: update.total,
|
|
52363
|
-
completed: update.completed,
|
|
52364
|
-
currentDeviceId: update.deviceId,
|
|
52365
|
-
failed: update.failed,
|
|
52366
|
-
mediaDropped: update.mediaDropped,
|
|
52367
|
-
tracks: update.tracks,
|
|
52368
|
-
events: update.events
|
|
52369
|
-
};
|
|
52370
|
-
deps.onProgress?.(update);
|
|
52371
|
-
deps.log?.("pipeline disk reconcile camera", {
|
|
52372
|
-
deviceId: update.deviceId,
|
|
52373
|
-
completed: update.completed,
|
|
52374
|
-
total: update.total,
|
|
52375
|
-
failed: update.failed.length,
|
|
52376
|
-
mediaDropped: update.mediaDropped,
|
|
52377
|
-
tracks: update.tracks,
|
|
52378
|
-
events: update.events
|
|
52379
|
-
});
|
|
52380
|
-
}
|
|
52381
|
-
});
|
|
52382
|
-
job = {
|
|
52383
|
-
...job,
|
|
52384
|
-
state: "done",
|
|
52385
|
-
total: result.cameras + result.failed.length,
|
|
52386
|
-
completed: result.cameras + result.failed.length,
|
|
52387
|
-
currentDeviceId: null,
|
|
52388
|
-
failed: result.failed,
|
|
52389
|
-
mediaDropped: result.mediaDropped,
|
|
52390
|
-
tracks: result.tracks,
|
|
52391
|
-
events: result.events,
|
|
52392
|
-
finishedAtMs: now(),
|
|
52393
|
-
error: null
|
|
52394
|
-
};
|
|
52395
|
-
deps.log?.("pipeline disk reconcile", {
|
|
52396
|
-
cameras: result.cameras,
|
|
52397
|
-
failed: result.failed,
|
|
52398
|
-
mediaDropped: result.mediaDropped,
|
|
52399
|
-
tracks: result.tracks,
|
|
52400
|
-
events: result.events
|
|
52401
|
-
});
|
|
52402
|
-
} catch (err) {
|
|
52403
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
52404
|
-
job = {
|
|
52405
|
-
...job,
|
|
52406
|
-
state: "error",
|
|
52407
|
-
currentDeviceId: null,
|
|
52408
|
-
finishedAtMs: now(),
|
|
52409
|
-
error
|
|
52410
|
-
};
|
|
52411
|
-
deps.log?.("pipeline disk reconcile failed", { error });
|
|
52412
|
-
} finally {
|
|
52413
|
-
inFlight = null;
|
|
52414
|
-
}
|
|
52415
|
-
})();
|
|
52416
|
-
return job;
|
|
52417
|
-
};
|
|
52418
|
-
return {
|
|
52419
|
-
snapshot,
|
|
52420
|
-
start
|
|
52421
|
-
};
|
|
52422
|
-
}
|
|
52423
|
-
//#endregion
|
|
52424
|
-
//#region src/widget-catalog.ts
|
|
52425
|
-
var pipelineOrchestratorWidgets = [{
|
|
52426
|
-
tab: "device-tab",
|
|
52427
|
-
label: "Pipeline Quick Stats",
|
|
52428
|
-
preAuth: false,
|
|
52429
|
-
kind: "remote",
|
|
52430
|
-
remote: {
|
|
52431
|
-
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
52432
|
-
exposedModule: "./widgets",
|
|
52433
|
-
componentKey: "pipeline-quick-stats"
|
|
52434
|
-
},
|
|
52435
|
-
stableId: "pipeline-quick-stats",
|
|
52436
|
-
description: "Phase / Detection FPS / Inference / Active Tracks tile row.",
|
|
52437
|
-
icon: "activity",
|
|
52438
|
-
bundle: "remoteEntry.js",
|
|
52439
|
-
hosts: ["device-tab", "dashboard"],
|
|
52440
|
-
requires: {
|
|
52441
|
-
deviceContext: true,
|
|
52442
|
-
integrationContext: false
|
|
52443
|
-
},
|
|
52444
|
-
defaultSize: "md",
|
|
52445
|
-
allowedSizes: [
|
|
52446
|
-
"sm",
|
|
52447
|
-
"md",
|
|
52448
|
-
"lg"
|
|
52449
|
-
],
|
|
52450
|
-
defaultColumns: 6,
|
|
52451
|
-
defaultRows: 1
|
|
52452
|
-
}, {
|
|
52453
|
-
tab: "device-tab",
|
|
52454
|
-
label: "Zone Editor",
|
|
52455
|
-
preAuth: false,
|
|
52456
|
-
kind: "remote",
|
|
52457
|
-
remote: {
|
|
52458
|
-
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
52459
|
-
exposedModule: "./widgets",
|
|
52460
|
-
componentKey: "zone-editor"
|
|
52461
|
-
},
|
|
52462
|
-
stableId: "zone-editor",
|
|
52463
|
-
description: "Polygon / tripwire CRUD + per-stage rule editor.",
|
|
52464
|
-
icon: "shapes",
|
|
52465
|
-
bundle: "remoteEntry.js",
|
|
52466
|
-
hosts: ["device-tab"],
|
|
52467
|
-
requires: {
|
|
52468
|
-
deviceContext: true,
|
|
52469
|
-
integrationContext: false
|
|
52470
|
-
},
|
|
52471
|
-
defaultSize: "xl",
|
|
52472
|
-
allowedSizes: ["lg", "xl"],
|
|
52473
|
-
defaultColumns: 12,
|
|
52474
|
-
defaultRows: 4
|
|
52475
|
-
}];
|
|
52476
|
-
//#endregion
|
|
52477
52743
|
//#region src/settings-ui-schemas.ts
|
|
52478
52744
|
/** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
|
|
52479
52745
|
function buildGlobalSettingsSections(options) {
|
|
@@ -52838,6 +53104,22 @@ function buildDeviceSettingsSections(nodeOptions) {
|
|
|
52838
53104
|
field: "detectionMode",
|
|
52839
53105
|
notEquals: "disabled"
|
|
52840
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
|
+
}
|
|
52841
53123
|
}
|
|
52842
53124
|
]
|
|
52843
53125
|
},
|
|
@@ -52927,6 +53209,99 @@ function deriveRuntimeSettings(config) {
|
|
|
52927
53209
|
};
|
|
52928
53210
|
}
|
|
52929
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
|
|
52930
53305
|
//#region src/index.ts
|
|
52931
53306
|
/**
|
|
52932
53307
|
* addon-pipeline-orchestrator — hub-side camera-to-agent load balancer.
|
|
@@ -53241,6 +53616,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
53241
53616
|
isCapActiveForDevice: (deviceId, capName) => this.isCapActiveForDevice(deviceId, capName),
|
|
53242
53617
|
isAudioAnalysisActive: (deviceId) => this.isAudioAnalysisActive(deviceId),
|
|
53243
53618
|
deviceHasOnboardMotionCap: (deviceId) => this.deviceHasOnboardMotionCap(deviceId),
|
|
53619
|
+
deviceHasActivitySignalCap: (deviceId) => this.deviceHasActivitySignalCap(deviceId),
|
|
53244
53620
|
deviceHasNativeObjectDetectionCap: (deviceId) => this.deviceHasNativeObjectDetectionCap(deviceId),
|
|
53245
53621
|
handleDeviceRegistered: (deviceId) => this.handleDeviceRegistered(deviceId),
|
|
53246
53622
|
handleDeviceUnregistered: (deviceId) => this.handleDeviceUnregistered(deviceId),
|
|
@@ -54427,6 +54803,26 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
54427
54803
|
* we err toward the analyzer (never lose detection coverage when the
|
|
54428
54804
|
* binding lookup hiccups).
|
|
54429
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
|
+
}
|
|
54430
54826
|
async deviceHasOnboardMotionCap(deviceId) {
|
|
54431
54827
|
const api = this.api;
|
|
54432
54828
|
if (!api) return false;
|