@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.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
//#region ../types/dist/event-category-
|
|
5
|
+
//#region ../types/dist/event-category-CnLqLOKs.mjs
|
|
6
6
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
7
7
|
EventCategory["SystemBoot"] = "system.boot";
|
|
8
8
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -197,6 +197,19 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
197
197
|
EventCategory["ProcessCrashed"] = "process.crashed";
|
|
198
198
|
EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
|
|
199
199
|
EventCategory["ProcessRestarted"] = "process.restarted";
|
|
200
|
+
/**
|
|
201
|
+
* The SET of storage locations changed — one was created, edited, enabled,
|
|
202
|
+
* disabled or deleted through `storage.upsertLocation` / `deleteLocation`.
|
|
203
|
+
*
|
|
204
|
+
* Telemetry, not a transaction (D8/D11): every consumer that re-resolves on
|
|
205
|
+
* it must also converge on its own periodic path, because a dropped event
|
|
206
|
+
* must not leave a node writing to yesterday's disk set forever. It exists
|
|
207
|
+
* because there was NO signal at all — an operator who added a second
|
|
208
|
+
* recordings disk in the admin UI got nothing, and the recorder kept its
|
|
209
|
+
* resolved locations until something else happened to re-resolve them
|
|
210
|
+
* (D387). Payload `StorageLocationsChangedPayload`.
|
|
211
|
+
*/
|
|
212
|
+
EventCategory["StorageLocationsChanged"] = "storage.locations-changed";
|
|
200
213
|
EventCategory["RecordingStarted"] = "recording.started";
|
|
201
214
|
EventCategory["RecordingStopped"] = "recording.stopped";
|
|
202
215
|
EventCategory["RecordingError"] = "recording.error";
|
|
@@ -9384,6 +9397,21 @@ var StorageCleanupJobSchema = object({
|
|
|
9384
9397
|
});
|
|
9385
9398
|
var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
|
|
9386
9399
|
/**
|
|
9400
|
+
* The one typed state of a storage location. Authoritative Zod schema — the TS
|
|
9401
|
+
* alias below is `z.infer<>` of it, never a second spelling.
|
|
9402
|
+
*/
|
|
9403
|
+
var StorageLocationModeSchema = _enum([
|
|
9404
|
+
"active",
|
|
9405
|
+
"readonly",
|
|
9406
|
+
"drain",
|
|
9407
|
+
"disabled"
|
|
9408
|
+
]);
|
|
9409
|
+
_enum([
|
|
9410
|
+
"normal",
|
|
9411
|
+
"never",
|
|
9412
|
+
"drain"
|
|
9413
|
+
]);
|
|
9414
|
+
/**
|
|
9387
9415
|
* `StorageLocationType` — an addon-declared id that identifies the *kind* of
|
|
9388
9416
|
* storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
|
|
9389
9417
|
* so the persisted record schema and the consumer-facing cap can both consume it
|
|
@@ -9449,6 +9477,21 @@ var StorageLocationSchema = object({
|
|
|
9449
9477
|
* stops existing rather than being re-derived on every read.
|
|
9450
9478
|
*/
|
|
9451
9479
|
enabled: boolean().optional(),
|
|
9480
|
+
/**
|
|
9481
|
+
* THE state of this location (D385), and the only authority on what may be
|
|
9482
|
+
* written, read or evicted here. Interpreted in exactly one place —
|
|
9483
|
+
* `storage-location-mode.ts` — which also folds the legacy
|
|
9484
|
+
* `enabled` / `config.readOnly` pair into a mode so an old row is never
|
|
9485
|
+
* ambiguous.
|
|
9486
|
+
*
|
|
9487
|
+
* OPTIONAL only for the wire and for rows written before D385: absence is
|
|
9488
|
+
* resolved by `resolveLocationMode`, and the orchestrator stamps every
|
|
9489
|
+
* unstamped row ONCE at hydrate so absence stops existing rather than being
|
|
9490
|
+
* re-derived on every read. `enabled` survives one release as a DERIVED
|
|
9491
|
+
* mirror (`mode === 'active'`); `withLocationMode` is the only writer of
|
|
9492
|
+
* either, so the two cannot disagree.
|
|
9493
|
+
*/
|
|
9494
|
+
mode: StorageLocationModeSchema.optional(),
|
|
9452
9495
|
/** COMPUTED at read time by the orchestrator (statfs of the backing volume
|
|
9453
9496
|
* for node-local locations it can reach) — never persisted, absent when the
|
|
9454
9497
|
* volume is remote/unreachable. The single capacity truth every UI reads. */
|
|
@@ -9456,11 +9499,46 @@ var StorageLocationSchema = object({
|
|
|
9456
9499
|
totalBytes: number(),
|
|
9457
9500
|
availableBytes: number()
|
|
9458
9501
|
}).nullable().optional(),
|
|
9502
|
+
/**
|
|
9503
|
+
* How much of that volume CamStack ITSELF holds on this location (D388) —
|
|
9504
|
+
* COMPUTED at read time from the `storage-occupancy` providers' own figures,
|
|
9505
|
+
* never persisted, never a filesystem walk.
|
|
9506
|
+
*
|
|
9507
|
+
* **ABSENT MEANS UNKNOWN, never zero.** No provider has reported for this
|
|
9508
|
+
* location yet — nobody stores here, the owning addon is down, or the first
|
|
9509
|
+
* refresh has not completed. A UI must omit the segment rather than draw it
|
|
9510
|
+
* at zero, which would claim we occupy nothing (D315). It is an OBJECT and
|
|
9511
|
+
* not a bare number precisely so that a `?? 0` on the consuming side has to
|
|
9512
|
+
* be spelled out loud instead of appearing by accident.
|
|
9513
|
+
*
|
|
9514
|
+
* `measuredAtMs` is the OLDEST contributing measurement, so it is honest
|
|
9515
|
+
* about the whole figure rather than about its freshest part.
|
|
9516
|
+
*/
|
|
9517
|
+
owned: object({
|
|
9518
|
+
bytes: number().int().nonnegative(),
|
|
9519
|
+
measuredAtMs: number().int().nonnegative()
|
|
9520
|
+
}).optional(),
|
|
9459
9521
|
createdAt: number(),
|
|
9460
9522
|
updatedAt: number()
|
|
9461
9523
|
});
|
|
9462
9524
|
object({ isDefault: boolean().optional() });
|
|
9463
9525
|
/**
|
|
9526
|
+
* How far a `drain` has got (D386) — the read a UI renders, and nothing more.
|
|
9527
|
+
*
|
|
9528
|
+
* `estimatedEmptyAtMs` is derived from the growth the ratchet has actually
|
|
9529
|
+
* OBSERVED and is `null` when it has observed none. Never a fabricated date: a
|
|
9530
|
+
* drain with no observed growth has no honest ETA, and inventing one is how an
|
|
9531
|
+
* operator learns not to believe the screen.
|
|
9532
|
+
*/
|
|
9533
|
+
var StorageDrainProgressSchema = object({
|
|
9534
|
+
locationId: string(),
|
|
9535
|
+
startedAtMs: number(),
|
|
9536
|
+
startBytes: number(),
|
|
9537
|
+
bytesRemaining: number(),
|
|
9538
|
+
drained: boolean(),
|
|
9539
|
+
estimatedEmptyAtMs: number().nullable()
|
|
9540
|
+
});
|
|
9541
|
+
/**
|
|
9464
9542
|
* Reference accepted by consumer-facing `api.storage.*` calls.
|
|
9465
9543
|
* Either:
|
|
9466
9544
|
* - a `StorageLocationType` (e.g. `'backups'`) → the sole location of that type
|
|
@@ -21204,8 +21282,25 @@ var occupancyRecheckFramesField = {
|
|
|
21204
21282
|
* (analyzer attaches detected `regions[]`; onboard does not — the
|
|
21205
21283
|
* camera typically only reports a binary signal plus an optional
|
|
21206
21284
|
* channel/AI class which lives in dedicated event channels).
|
|
21207
|
-
|
|
21208
|
-
|
|
21285
|
+
*
|
|
21286
|
+
* - `onboard` — the camera's firmware said something moved.
|
|
21287
|
+
* - `analyzer` — this runner's frame-diff said so, and attaches `regions[]`.
|
|
21288
|
+
* - `device-activity` — the DEVICE said it is doing its job: the
|
|
21289
|
+
* `recording-signal` LEVEL the same device raises for the recorder
|
|
21290
|
+
* ([D380](../../../../docs/decisions/adr-0380-a-device-decided-recording-is-a-mode-with-no-schedule-seeded-once.md)),
|
|
21291
|
+
* republished as a motion source. It attaches **nothing** — no regions, no
|
|
21292
|
+
* class: the only fact it carries is that the device is active, and a robot
|
|
21293
|
+
* vacuum that is itself the moving object has no region worth sending. It is
|
|
21294
|
+
* a LEVEL, so unlike `onboard` it has a real falling edge, and unlike
|
|
21295
|
+
* `analyzer` it must not open the frame-diff side-channel — the runner's
|
|
21296
|
+
* `handleOnboardMotionAnalyzer` gate is `source === 'onboard'` and stays that
|
|
21297
|
+
* way ([D392](../../../../docs/decisions/adr-0392-a-device-that-says-it-is-working-is-a-motion-source-of-its-own.md)).
|
|
21298
|
+
*/
|
|
21299
|
+
var MotionSourceEnum = _enum([
|
|
21300
|
+
"onboard",
|
|
21301
|
+
"analyzer",
|
|
21302
|
+
"device-activity"
|
|
21303
|
+
]);
|
|
21209
21304
|
/**
|
|
21210
21305
|
* List of motion sources active on a camera. Empty array is valid:
|
|
21211
21306
|
* "no source" — happens for battery cams without firmware motion when
|
|
@@ -21469,13 +21564,20 @@ var RunnerCameraDeviceUIFields = [
|
|
|
21469
21564
|
type: "multiselect",
|
|
21470
21565
|
label: "Motion Sources",
|
|
21471
21566
|
default: ["analyzer"],
|
|
21472
|
-
options: [
|
|
21473
|
-
|
|
21474
|
-
|
|
21475
|
-
|
|
21476
|
-
|
|
21477
|
-
|
|
21478
|
-
|
|
21567
|
+
options: [
|
|
21568
|
+
{
|
|
21569
|
+
value: "analyzer",
|
|
21570
|
+
label: "Frame-diff Analyzer (motion addon)"
|
|
21571
|
+
},
|
|
21572
|
+
{
|
|
21573
|
+
value: "onboard",
|
|
21574
|
+
label: "Camera Onboard Sensor"
|
|
21575
|
+
},
|
|
21576
|
+
{
|
|
21577
|
+
value: "device-activity",
|
|
21578
|
+
label: "Device activity (the device says it is working)"
|
|
21579
|
+
}
|
|
21580
|
+
]
|
|
21479
21581
|
},
|
|
21480
21582
|
{
|
|
21481
21583
|
key: "motionFps",
|
|
@@ -23212,7 +23314,7 @@ method(object({
|
|
|
23212
23314
|
}), _void(), {
|
|
23213
23315
|
kind: "mutation",
|
|
23214
23316
|
auth: "admin"
|
|
23215
|
-
}), method(object({ id: string() }), object({
|
|
23317
|
+
}), method(_void(), array(StorageDrainProgressSchema).readonly()), method(object({ id: string() }), object({
|
|
23216
23318
|
ok: boolean(),
|
|
23217
23319
|
error: string().optional()
|
|
23218
23320
|
}), { auth: "admin" }), method(_void(), array(ProviderListEntrySchema).readonly()), method(object({
|
|
@@ -23282,6 +23384,51 @@ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin"
|
|
|
23282
23384
|
kind: "mutation",
|
|
23283
23385
|
auth: "admin"
|
|
23284
23386
|
}), method(object({}), array(StorageMigrationJobSchema).readonly(), { auth: "admin" });
|
|
23387
|
+
/**
|
|
23388
|
+
* `storage-occupancy` — how many bytes an addon actually HOLDS on a storage
|
|
23389
|
+
* location (D388).
|
|
23390
|
+
*
|
|
23391
|
+
* ## Why this is not `storage-evictable`
|
|
23392
|
+
*
|
|
23393
|
+
* `storage-evictable.getEvictableUsage` looks like the same question and is
|
|
23394
|
+
* not, in two ways that both matter and both bite hardest on the locations an
|
|
23395
|
+
* operator most wants a figure for:
|
|
23396
|
+
*
|
|
23397
|
+
* - it reports the whole eviction DOMAIN, not the location. `recordings:default`
|
|
23398
|
+
* and `recordingsLow:default` deliberately share one root and evict as one
|
|
23399
|
+
* oldest-first pool, so both answer with the SAME combined total. As an
|
|
23400
|
+
* occupancy figure that double-counts the disk.
|
|
23401
|
+
* - it reports ZERO for a location whose eviction policy is `never` (D385) —
|
|
23402
|
+
* a `readonly` or `disabled` disk. Those are exactly the disks an operator
|
|
23403
|
+
* is retiring and staring at.
|
|
23404
|
+
*
|
|
23405
|
+
* So this is its own contract with its own quantity, and the quantity is
|
|
23406
|
+
* OCCUPIED: every byte the addon holds on that location, whether or not it
|
|
23407
|
+
* would ever be willing to delete it. A provider that can only answer
|
|
23408
|
+
* "evictable" must not register here — a number that silently means different
|
|
23409
|
+
* things per class is worse than no number.
|
|
23410
|
+
*
|
|
23411
|
+
* ## Absence is an answer
|
|
23412
|
+
*
|
|
23413
|
+
* A location nobody reports for is UNKNOWN, never zero (D315). The orchestrator
|
|
23414
|
+
* stamps `StorageLocation.owned` only for locations it has a report for, and
|
|
23415
|
+
* the field is an OBJECT rather than a bare number so that a `?? 0` on the
|
|
23416
|
+
* consuming side has to be written out loud instead of appearing by accident.
|
|
23417
|
+
*
|
|
23418
|
+
* `internal: true` — consumed by the orchestrator's `listLocations` stamp, never
|
|
23419
|
+
* a public client surface. Clients read the stamped `StorageLocation.owned`.
|
|
23420
|
+
*/
|
|
23421
|
+
/** One provider's occupancy answer for one location. */
|
|
23422
|
+
var StorageOccupancyReportSchema = object({
|
|
23423
|
+
locationId: string(),
|
|
23424
|
+
/** Bytes this provider holds on THAT location — not its eviction domain, and
|
|
23425
|
+
* not net of what it is willing to delete. */
|
|
23426
|
+
ownedBytes: number().int().nonnegative(),
|
|
23427
|
+
/** When the provider last actually measured this. The orchestrator carries it
|
|
23428
|
+
* through so a UI can say how old the figure is instead of implying "now". */
|
|
23429
|
+
measuredAtMs: number().int().nonnegative()
|
|
23430
|
+
});
|
|
23431
|
+
method(object({ locationIds: array(string()).readonly() }), array(StorageOccupancyReportSchema).readonly(), { auth: "admin" });
|
|
23285
23432
|
var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
23286
23433
|
providerId: string().min(1),
|
|
23287
23434
|
displayName: string().min(1),
|
|
@@ -24917,39 +25064,6 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, meth
|
|
|
24917
25064
|
deviceId: number(),
|
|
24918
25065
|
status: BatteryStatusSchema
|
|
24919
25066
|
});
|
|
24920
|
-
/**
|
|
24921
|
-
* Network-link snapshot. Same shape for every provider (a Reolink wifi
|
|
24922
|
-
* camera, a Home Assistant device with a signal-strength sensor, a Tapo
|
|
24923
|
-
* plug): one slice under `device.runtimeState['network-link']`, one badge,
|
|
24924
|
-
* one Home Assistant projection.
|
|
24925
|
-
*/
|
|
24926
|
-
var NetworkLinkStatusSchema = object({
|
|
24927
|
-
/** The link the device is on. `'unknown'` = not read yet, not "no link". */
|
|
24928
|
-
type: _enum([
|
|
24929
|
-
"wifi",
|
|
24930
|
-
"ethernet",
|
|
24931
|
-
"cellular",
|
|
24932
|
-
"unknown"
|
|
24933
|
-
]),
|
|
24934
|
-
/**
|
|
24935
|
-
* Link quality, 0..100 inclusive, normalised by the provider from whatever
|
|
24936
|
-
* the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
|
|
24937
|
-
* KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
|
|
24938
|
-
* one whose reading has not landed must not be drawn at 0 %. Consumers
|
|
24939
|
-
* SKIP a null rather than coerce it.
|
|
24940
|
-
*/
|
|
24941
|
-
signalPercent: number().min(0).max(100).nullable(),
|
|
24942
|
-
/** Raw received signal strength in dBm, when the firmware reports one. */
|
|
24943
|
-
rssiDbm: number().optional(),
|
|
24944
|
-
/** Network name of a wireless link, when the firmware reports it. */
|
|
24945
|
-
ssid: string().optional(),
|
|
24946
|
-
/** Ms epoch of the last observation. Lets consumers reason about freshness. */
|
|
24947
|
-
lastUpdated: number()
|
|
24948
|
-
});
|
|
24949
|
-
DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
|
|
24950
|
-
deviceId: number(),
|
|
24951
|
-
status: NetworkLinkStatusSchema
|
|
24952
|
-
});
|
|
24953
25067
|
object({
|
|
24954
25068
|
on: boolean(),
|
|
24955
25069
|
/** Ms epoch of the last transition. 0 if never observed. */
|
|
@@ -27303,6 +27417,236 @@ DeviceType.Camera, method(object({
|
|
|
27303
27417
|
detection: NativeDetectionSchema
|
|
27304
27418
|
});
|
|
27305
27419
|
/**
|
|
27420
|
+
* `navigation` — a device-scoped capability that natively expresses the FULL
|
|
27421
|
+
* navigation / action surface of a robot that DRIVES ITSELF and carries an
|
|
27422
|
+
* on-board camera (the Dreame robot-vacuum camera is the first provider).
|
|
27423
|
+
*
|
|
27424
|
+
* Why a NEW cap rather than overloading `ptz`:
|
|
27425
|
+
* - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
|
|
27426
|
+
* robot vacuum has no gimbal — the whole chassis drives, turns and spins.
|
|
27427
|
+
* The two are different physical models: PTZ is absolute-position + presets,
|
|
27428
|
+
* navigation is momentary drive nudges + discrete robot ACTIONS
|
|
27429
|
+
* (dock / spot-clean / follow-pet / go-to-point / …).
|
|
27430
|
+
* - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
|
|
27431
|
+
* the reverse:
|
|
27432
|
+
* 1. a native CamStack navigation panel (data-driven from `listActions`
|
|
27433
|
+
* / `getOptions`), and
|
|
27434
|
+
* 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
|
|
27435
|
+
* robot camera shows up in the existing PTZ control path without every
|
|
27436
|
+
* PTZ provider learning about robots. The mapping lives in the adapter,
|
|
27437
|
+
* not here (see the addon design note):
|
|
27438
|
+
* ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
|
|
27439
|
+
* ptz.stop() → navigation.stop()
|
|
27440
|
+
* ptz.goHome() → navigation.runAction('goHome')
|
|
27441
|
+
* ptz.getPresets() → navigation.listActions() (id→preset)
|
|
27442
|
+
* ptz.goToPreset(id) → navigation.runAction(id)
|
|
27443
|
+
*
|
|
27444
|
+
* ## Continuous drive
|
|
27445
|
+
*
|
|
27446
|
+
* `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
|
|
27447
|
+
* sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
|
|
27448
|
+
* one `stop()` on release — exactly like the robot app's remote-drive joystick.
|
|
27449
|
+
* The provider forwards EACH `move` to one drive write; it must NOT debounce or
|
|
27450
|
+
* coalesce them. The UI owns the cadence.
|
|
27451
|
+
*
|
|
27452
|
+
* ## The action dictionary
|
|
27453
|
+
*
|
|
27454
|
+
* The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
|
|
27455
|
+
* are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
|
|
27456
|
+
* carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
|
|
27457
|
+
* native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
|
|
27458
|
+
* vendor-specific list. `kind: 'action'` entries are triggered with
|
|
27459
|
+
* `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
|
|
27460
|
+
* (the entry carries the `soundId` to pass). The general primitives — `move`,
|
|
27461
|
+
* `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
|
|
27462
|
+
*
|
|
27463
|
+
* NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
|
|
27464
|
+
* `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
|
|
27465
|
+
* that the currently-published `@apocaliss92/nodedreame` already exposes on
|
|
27466
|
+
* every device handle. A future nodedreame publish adds a typed
|
|
27467
|
+
* `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
|
|
27468
|
+
* provider can then swap the raw calls for the typed methods with no change to
|
|
27469
|
+
* THIS contract.
|
|
27470
|
+
*/
|
|
27471
|
+
/**
|
|
27472
|
+
* A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
|
|
27473
|
+
* keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
|
|
27474
|
+
* halts it.
|
|
27475
|
+
*
|
|
27476
|
+
* - `pan` — turn: negative = left, positive = right, 0 = straight.
|
|
27477
|
+
* - `tilt` — throttle: positive = forward, negative = spin / turn-around.
|
|
27478
|
+
* - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
|
|
27479
|
+
* vector by it (drivers without proportional drive ignore it).
|
|
27480
|
+
*
|
|
27481
|
+
* `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
|
|
27482
|
+
* axis alone; an all-undefined nudge is a no-op.
|
|
27483
|
+
*/
|
|
27484
|
+
var NavigationMoveCommandSchema = object({
|
|
27485
|
+
pan: number().min(-1).max(1).optional(),
|
|
27486
|
+
tilt: number().min(-1).max(1).optional(),
|
|
27487
|
+
speed: number().min(0).max(1).optional()
|
|
27488
|
+
});
|
|
27489
|
+
/**
|
|
27490
|
+
* The enumerated discrete actions a navigation-capable robot can perform via
|
|
27491
|
+
* `runAction`. This is the CLOSED vocabulary; a given device advertises the
|
|
27492
|
+
* subset it supports through `listActions`. Sounds are NOT here — they go through
|
|
27493
|
+
* `playSound` (see the `sound` dictionary entries).
|
|
27494
|
+
*/
|
|
27495
|
+
var NavigationActionIdSchema = _enum([
|
|
27496
|
+
"goHome",
|
|
27497
|
+
"locate",
|
|
27498
|
+
"spotClean",
|
|
27499
|
+
"findPet",
|
|
27500
|
+
"personFollow",
|
|
27501
|
+
"stop",
|
|
27502
|
+
"startClean",
|
|
27503
|
+
"pauseClean",
|
|
27504
|
+
"dockWash",
|
|
27505
|
+
"autoEmpty",
|
|
27506
|
+
"flashOn",
|
|
27507
|
+
"flashOff"
|
|
27508
|
+
]);
|
|
27509
|
+
/** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
|
|
27510
|
+
var NavigationEntryKindSchema = _enum(["action", "sound"]);
|
|
27511
|
+
/**
|
|
27512
|
+
* One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
|
|
27513
|
+
* native panel and the PTZ mimic render as a button.
|
|
27514
|
+
*
|
|
27515
|
+
* - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
|
|
27516
|
+
* (pass to `runAction`); for `kind:'sound'` it is a namespaced id
|
|
27517
|
+
* (`sound:meow`) whose `soundId` is passed to `playSound`.
|
|
27518
|
+
* - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
|
|
27519
|
+
* - `label` — operator-facing English label.
|
|
27520
|
+
* - `soundId` — wire sound id, present only on `kind:'sound'` entries.
|
|
27521
|
+
* - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
|
|
27522
|
+
* PTZ render ONLY enabled entries. Data-driven: the provider
|
|
27523
|
+
* flips it from config, never by editing code.
|
|
27524
|
+
*/
|
|
27525
|
+
var NavigationActionEntrySchema = object({
|
|
27526
|
+
id: string(),
|
|
27527
|
+
kind: NavigationEntryKindSchema,
|
|
27528
|
+
label: string(),
|
|
27529
|
+
icon: string(),
|
|
27530
|
+
/** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
|
|
27531
|
+
soundId: number().int().optional(),
|
|
27532
|
+
/** Per-device feature flag — render this entry only when true. */
|
|
27533
|
+
enabled: boolean()
|
|
27534
|
+
});
|
|
27535
|
+
/** Coordinates for `goToPoint` — a point on the robot's live map. */
|
|
27536
|
+
var NavigationPointSchema = object({
|
|
27537
|
+
x: number(),
|
|
27538
|
+
y: number()
|
|
27539
|
+
});
|
|
27540
|
+
/**
|
|
27541
|
+
* Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
|
|
27542
|
+
* The cap reports which are enabled so the UI / PTZ render only the controls
|
|
27543
|
+
* that are turned on for THIS device. Data-driven: the provider derives these
|
|
27544
|
+
* from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
|
|
27545
|
+
* live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
|
|
27546
|
+
* that are not dictionary entries.
|
|
27547
|
+
*
|
|
27548
|
+
* - `move` / `stop` — the momentary drive joystick.
|
|
27549
|
+
* - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
|
|
27550
|
+
* map-coordinate plumbing is wired.
|
|
27551
|
+
* - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
|
|
27552
|
+
* - `playSound` — the sound buttons (dictionary `kind:'sound'`).
|
|
27553
|
+
* - `light` — the on/off fill-light toggle (works anytime).
|
|
27554
|
+
* - `lightMode` — the auto/manual selector + manual level slider (a
|
|
27555
|
+
* camera-service control; needs an active stream).
|
|
27556
|
+
*/
|
|
27557
|
+
var NavigationFeaturesSchema = object({
|
|
27558
|
+
move: boolean(),
|
|
27559
|
+
stop: boolean(),
|
|
27560
|
+
goToPoint: boolean(),
|
|
27561
|
+
runAction: boolean(),
|
|
27562
|
+
playSound: boolean(),
|
|
27563
|
+
light: boolean(),
|
|
27564
|
+
lightMode: boolean()
|
|
27565
|
+
});
|
|
27566
|
+
/** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
|
|
27567
|
+
var NavigationLightModeSchema = _enum(["auto", "manual"]);
|
|
27568
|
+
/**
|
|
27569
|
+
* Live navigation state so the UI can reflect what the robot is doing:
|
|
27570
|
+
* - `mode` — coarse activity (idle / cleaning / following / …).
|
|
27571
|
+
* - `following` — person/pet follow is currently armed.
|
|
27572
|
+
* - `flash` — the on-camera fill light is on.
|
|
27573
|
+
* - `lightMode` — auto vs manual fill-light mode.
|
|
27574
|
+
* - `lightLevel` — manual fill-light level (40..100); meaningful when
|
|
27575
|
+
* `lightMode === 'manual'`.
|
|
27576
|
+
*/
|
|
27577
|
+
var NavigationStatusSchema = object({
|
|
27578
|
+
mode: _enum([
|
|
27579
|
+
"idle",
|
|
27580
|
+
"cleaning",
|
|
27581
|
+
"spot",
|
|
27582
|
+
"following",
|
|
27583
|
+
"goto",
|
|
27584
|
+
"returning",
|
|
27585
|
+
"paused",
|
|
27586
|
+
"unknown"
|
|
27587
|
+
]),
|
|
27588
|
+
following: boolean(),
|
|
27589
|
+
flash: boolean(),
|
|
27590
|
+
lightMode: NavigationLightModeSchema,
|
|
27591
|
+
lightLevel: number().min(40).max(100),
|
|
27592
|
+
/** Ms epoch when the slice was last updated. */
|
|
27593
|
+
lastChangedAt: number()
|
|
27594
|
+
});
|
|
27595
|
+
NavigationStatusSchema.extend({ lastFetchedAt: number() });
|
|
27596
|
+
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({
|
|
27597
|
+
deviceId: number(),
|
|
27598
|
+
actionId: NavigationActionIdSchema
|
|
27599
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27600
|
+
deviceId: number(),
|
|
27601
|
+
soundId: number().int()
|
|
27602
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27603
|
+
deviceId: number(),
|
|
27604
|
+
on: boolean()
|
|
27605
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27606
|
+
deviceId: number(),
|
|
27607
|
+
mode: NavigationLightModeSchema,
|
|
27608
|
+
level: number().min(40).max(100).optional()
|
|
27609
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
27610
|
+
deviceId: number(),
|
|
27611
|
+
level: number().min(40).max(100)
|
|
27612
|
+
}), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
|
|
27613
|
+
deviceId: number(),
|
|
27614
|
+
status: NavigationStatusSchema
|
|
27615
|
+
});
|
|
27616
|
+
/**
|
|
27617
|
+
* Network-link snapshot. Same shape for every provider (a Reolink wifi
|
|
27618
|
+
* camera, a Home Assistant device with a signal-strength sensor, a Tapo
|
|
27619
|
+
* plug): one slice under `device.runtimeState['network-link']`, one badge,
|
|
27620
|
+
* one Home Assistant projection.
|
|
27621
|
+
*/
|
|
27622
|
+
var NetworkLinkStatusSchema = object({
|
|
27623
|
+
/** The link the device is on. `'unknown'` = not read yet, not "no link". */
|
|
27624
|
+
type: _enum([
|
|
27625
|
+
"wifi",
|
|
27626
|
+
"ethernet",
|
|
27627
|
+
"cellular",
|
|
27628
|
+
"unknown"
|
|
27629
|
+
]),
|
|
27630
|
+
/**
|
|
27631
|
+
* Link quality, 0..100 inclusive, normalised by the provider from whatever
|
|
27632
|
+
* the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
|
|
27633
|
+
* KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
|
|
27634
|
+
* one whose reading has not landed must not be drawn at 0 %. Consumers
|
|
27635
|
+
* SKIP a null rather than coerce it.
|
|
27636
|
+
*/
|
|
27637
|
+
signalPercent: number().min(0).max(100).nullable(),
|
|
27638
|
+
/** Raw received signal strength in dBm, when the firmware reports one. */
|
|
27639
|
+
rssiDbm: number().optional(),
|
|
27640
|
+
/** Network name of a wireless link, when the firmware reports it. */
|
|
27641
|
+
ssid: string().optional(),
|
|
27642
|
+
/** Ms epoch of the last observation. Lets consumers reason about freshness. */
|
|
27643
|
+
lastUpdated: number()
|
|
27644
|
+
});
|
|
27645
|
+
DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
|
|
27646
|
+
deviceId: number(),
|
|
27647
|
+
status: NetworkLinkStatusSchema
|
|
27648
|
+
});
|
|
27649
|
+
/**
|
|
27306
27650
|
* network-quality — system-scoped singleton capability tracking RTT,
|
|
27307
27651
|
* jitter, and observed/peak bandwidth per device + per client.
|
|
27308
27652
|
*
|
|
@@ -28544,203 +28888,6 @@ DeviceType.Camera, method(object({ deviceId: number() }), PtzAutotrackStatusSche
|
|
|
28544
28888
|
deviceId: number(),
|
|
28545
28889
|
status: PtzAutotrackStatusSchema
|
|
28546
28890
|
});
|
|
28547
|
-
/**
|
|
28548
|
-
* `navigation` — a device-scoped capability that natively expresses the FULL
|
|
28549
|
-
* navigation / action surface of a robot that DRIVES ITSELF and carries an
|
|
28550
|
-
* on-board camera (the Dreame robot-vacuum camera is the first provider).
|
|
28551
|
-
*
|
|
28552
|
-
* Why a NEW cap rather than overloading `ptz`:
|
|
28553
|
-
* - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
|
|
28554
|
-
* robot vacuum has no gimbal — the whole chassis drives, turns and spins.
|
|
28555
|
-
* The two are different physical models: PTZ is absolute-position + presets,
|
|
28556
|
-
* navigation is momentary drive nudges + discrete robot ACTIONS
|
|
28557
|
-
* (dock / spot-clean / follow-pet / go-to-point / …).
|
|
28558
|
-
* - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
|
|
28559
|
-
* the reverse:
|
|
28560
|
-
* 1. a native CamStack navigation panel (data-driven from `listActions`
|
|
28561
|
-
* / `getOptions`), and
|
|
28562
|
-
* 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
|
|
28563
|
-
* robot camera shows up in the existing PTZ control path without every
|
|
28564
|
-
* PTZ provider learning about robots. The mapping lives in the adapter,
|
|
28565
|
-
* not here (see the addon design note):
|
|
28566
|
-
* ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
|
|
28567
|
-
* ptz.stop() → navigation.stop()
|
|
28568
|
-
* ptz.goHome() → navigation.runAction('goHome')
|
|
28569
|
-
* ptz.getPresets() → navigation.listActions() (id→preset)
|
|
28570
|
-
* ptz.goToPreset(id) → navigation.runAction(id)
|
|
28571
|
-
*
|
|
28572
|
-
* ## Continuous drive
|
|
28573
|
-
*
|
|
28574
|
-
* `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
|
|
28575
|
-
* sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
|
|
28576
|
-
* one `stop()` on release — exactly like the robot app's remote-drive joystick.
|
|
28577
|
-
* The provider forwards EACH `move` to one drive write; it must NOT debounce or
|
|
28578
|
-
* coalesce them. The UI owns the cadence.
|
|
28579
|
-
*
|
|
28580
|
-
* ## The action dictionary
|
|
28581
|
-
*
|
|
28582
|
-
* The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
|
|
28583
|
-
* are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
|
|
28584
|
-
* carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
|
|
28585
|
-
* native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
|
|
28586
|
-
* vendor-specific list. `kind: 'action'` entries are triggered with
|
|
28587
|
-
* `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
|
|
28588
|
-
* (the entry carries the `soundId` to pass). The general primitives — `move`,
|
|
28589
|
-
* `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
|
|
28590
|
-
*
|
|
28591
|
-
* NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
|
|
28592
|
-
* `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
|
|
28593
|
-
* that the currently-published `@apocaliss92/nodedreame` already exposes on
|
|
28594
|
-
* every device handle. A future nodedreame publish adds a typed
|
|
28595
|
-
* `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
|
|
28596
|
-
* provider can then swap the raw calls for the typed methods with no change to
|
|
28597
|
-
* THIS contract.
|
|
28598
|
-
*/
|
|
28599
|
-
/**
|
|
28600
|
-
* A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
|
|
28601
|
-
* keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
|
|
28602
|
-
* halts it.
|
|
28603
|
-
*
|
|
28604
|
-
* - `pan` — turn: negative = left, positive = right, 0 = straight.
|
|
28605
|
-
* - `tilt` — throttle: positive = forward, negative = spin / turn-around.
|
|
28606
|
-
* - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
|
|
28607
|
-
* vector by it (drivers without proportional drive ignore it).
|
|
28608
|
-
*
|
|
28609
|
-
* `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
|
|
28610
|
-
* axis alone; an all-undefined nudge is a no-op.
|
|
28611
|
-
*/
|
|
28612
|
-
var NavigationMoveCommandSchema = object({
|
|
28613
|
-
pan: number().min(-1).max(1).optional(),
|
|
28614
|
-
tilt: number().min(-1).max(1).optional(),
|
|
28615
|
-
speed: number().min(0).max(1).optional()
|
|
28616
|
-
});
|
|
28617
|
-
/**
|
|
28618
|
-
* The enumerated discrete actions a navigation-capable robot can perform via
|
|
28619
|
-
* `runAction`. This is the CLOSED vocabulary; a given device advertises the
|
|
28620
|
-
* subset it supports through `listActions`. Sounds are NOT here — they go through
|
|
28621
|
-
* `playSound` (see the `sound` dictionary entries).
|
|
28622
|
-
*/
|
|
28623
|
-
var NavigationActionIdSchema = _enum([
|
|
28624
|
-
"goHome",
|
|
28625
|
-
"locate",
|
|
28626
|
-
"spotClean",
|
|
28627
|
-
"findPet",
|
|
28628
|
-
"personFollow",
|
|
28629
|
-
"stop",
|
|
28630
|
-
"startClean",
|
|
28631
|
-
"pauseClean",
|
|
28632
|
-
"dockWash",
|
|
28633
|
-
"autoEmpty",
|
|
28634
|
-
"flashOn",
|
|
28635
|
-
"flashOff"
|
|
28636
|
-
]);
|
|
28637
|
-
/** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
|
|
28638
|
-
var NavigationEntryKindSchema = _enum(["action", "sound"]);
|
|
28639
|
-
/**
|
|
28640
|
-
* One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
|
|
28641
|
-
* native panel and the PTZ mimic render as a button.
|
|
28642
|
-
*
|
|
28643
|
-
* - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
|
|
28644
|
-
* (pass to `runAction`); for `kind:'sound'` it is a namespaced id
|
|
28645
|
-
* (`sound:meow`) whose `soundId` is passed to `playSound`.
|
|
28646
|
-
* - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
|
|
28647
|
-
* - `label` — operator-facing English label.
|
|
28648
|
-
* - `soundId` — wire sound id, present only on `kind:'sound'` entries.
|
|
28649
|
-
* - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
|
|
28650
|
-
* PTZ render ONLY enabled entries. Data-driven: the provider
|
|
28651
|
-
* flips it from config, never by editing code.
|
|
28652
|
-
*/
|
|
28653
|
-
var NavigationActionEntrySchema = object({
|
|
28654
|
-
id: string(),
|
|
28655
|
-
kind: NavigationEntryKindSchema,
|
|
28656
|
-
label: string(),
|
|
28657
|
-
icon: string(),
|
|
28658
|
-
/** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
|
|
28659
|
-
soundId: number().int().optional(),
|
|
28660
|
-
/** Per-device feature flag — render this entry only when true. */
|
|
28661
|
-
enabled: boolean()
|
|
28662
|
-
});
|
|
28663
|
-
/** Coordinates for `goToPoint` — a point on the robot's live map. */
|
|
28664
|
-
var NavigationPointSchema = object({
|
|
28665
|
-
x: number(),
|
|
28666
|
-
y: number()
|
|
28667
|
-
});
|
|
28668
|
-
/**
|
|
28669
|
-
* Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
|
|
28670
|
-
* The cap reports which are enabled so the UI / PTZ render only the controls
|
|
28671
|
-
* that are turned on for THIS device. Data-driven: the provider derives these
|
|
28672
|
-
* from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
|
|
28673
|
-
* live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
|
|
28674
|
-
* that are not dictionary entries.
|
|
28675
|
-
*
|
|
28676
|
-
* - `move` / `stop` — the momentary drive joystick.
|
|
28677
|
-
* - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
|
|
28678
|
-
* map-coordinate plumbing is wired.
|
|
28679
|
-
* - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
|
|
28680
|
-
* - `playSound` — the sound buttons (dictionary `kind:'sound'`).
|
|
28681
|
-
* - `light` — the on/off fill-light toggle (works anytime).
|
|
28682
|
-
* - `lightMode` — the auto/manual selector + manual level slider (a
|
|
28683
|
-
* camera-service control; needs an active stream).
|
|
28684
|
-
*/
|
|
28685
|
-
var NavigationFeaturesSchema = object({
|
|
28686
|
-
move: boolean(),
|
|
28687
|
-
stop: boolean(),
|
|
28688
|
-
goToPoint: boolean(),
|
|
28689
|
-
runAction: boolean(),
|
|
28690
|
-
playSound: boolean(),
|
|
28691
|
-
light: boolean(),
|
|
28692
|
-
lightMode: boolean()
|
|
28693
|
-
});
|
|
28694
|
-
/** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
|
|
28695
|
-
var NavigationLightModeSchema = _enum(["auto", "manual"]);
|
|
28696
|
-
/**
|
|
28697
|
-
* Live navigation state so the UI can reflect what the robot is doing:
|
|
28698
|
-
* - `mode` — coarse activity (idle / cleaning / following / …).
|
|
28699
|
-
* - `following` — person/pet follow is currently armed.
|
|
28700
|
-
* - `flash` — the on-camera fill light is on.
|
|
28701
|
-
* - `lightMode` — auto vs manual fill-light mode.
|
|
28702
|
-
* - `lightLevel` — manual fill-light level (40..100); meaningful when
|
|
28703
|
-
* `lightMode === 'manual'`.
|
|
28704
|
-
*/
|
|
28705
|
-
var NavigationStatusSchema = object({
|
|
28706
|
-
mode: _enum([
|
|
28707
|
-
"idle",
|
|
28708
|
-
"cleaning",
|
|
28709
|
-
"spot",
|
|
28710
|
-
"following",
|
|
28711
|
-
"goto",
|
|
28712
|
-
"returning",
|
|
28713
|
-
"paused",
|
|
28714
|
-
"unknown"
|
|
28715
|
-
]),
|
|
28716
|
-
following: boolean(),
|
|
28717
|
-
flash: boolean(),
|
|
28718
|
-
lightMode: NavigationLightModeSchema,
|
|
28719
|
-
lightLevel: number().min(40).max(100),
|
|
28720
|
-
/** Ms epoch when the slice was last updated. */
|
|
28721
|
-
lastChangedAt: number()
|
|
28722
|
-
});
|
|
28723
|
-
NavigationStatusSchema.extend({ lastFetchedAt: number() });
|
|
28724
|
-
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({
|
|
28725
|
-
deviceId: number(),
|
|
28726
|
-
actionId: NavigationActionIdSchema
|
|
28727
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28728
|
-
deviceId: number(),
|
|
28729
|
-
soundId: number().int()
|
|
28730
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28731
|
-
deviceId: number(),
|
|
28732
|
-
on: boolean()
|
|
28733
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28734
|
-
deviceId: number(),
|
|
28735
|
-
mode: NavigationLightModeSchema,
|
|
28736
|
-
level: number().min(40).max(100).optional()
|
|
28737
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
28738
|
-
deviceId: number(),
|
|
28739
|
-
level: number().min(40).max(100)
|
|
28740
|
-
}), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
|
|
28741
|
-
deviceId: number(),
|
|
28742
|
-
status: NavigationStatusSchema
|
|
28743
|
-
});
|
|
28744
28891
|
DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
|
|
28745
28892
|
kind: "mutation",
|
|
28746
28893
|
auth: "admin"
|
|
@@ -29427,8 +29574,38 @@ var RecordingSignalStatusSchema = object({
|
|
|
29427
29574
|
/** Ms epoch of the last `active` transition. 0 if never observed. */
|
|
29428
29575
|
lastChangedAt: number()
|
|
29429
29576
|
});
|
|
29430
|
-
|
|
29431
|
-
|
|
29577
|
+
/** The runtime-state slice: the status plus the clock every slice carries. */
|
|
29578
|
+
var RecordingSignalRuntimeStateSchema = RecordingSignalStatusSchema.extend({ lastFetchedAt: number() });
|
|
29579
|
+
var recordingSignalCapability = {
|
|
29580
|
+
name: "recording-signal",
|
|
29581
|
+
scope: "device",
|
|
29582
|
+
deviceNative: true,
|
|
29583
|
+
mode: "singleton",
|
|
29584
|
+
deviceTypes: [DeviceType.Camera],
|
|
29585
|
+
methods: {
|
|
29586
|
+
/** The current level, straight from the slice the provider keeps fresh. */
|
|
29587
|
+
getStatus: method(object({ deviceId: number() }), RecordingSignalStatusSchema) },
|
|
29588
|
+
status: {
|
|
29589
|
+
schema: RecordingSignalStatusSchema,
|
|
29590
|
+
kind: "push",
|
|
29591
|
+
empty: {
|
|
29592
|
+
active: false,
|
|
29593
|
+
reason: "unknown",
|
|
29594
|
+
lastChangedAt: 0
|
|
29595
|
+
}
|
|
29596
|
+
},
|
|
29597
|
+
runtimeState: RecordingSignalRuntimeStateSchema,
|
|
29598
|
+
/**
|
|
29599
|
+
* Runtime-state durability: **session** — a restored `active: true` from
|
|
29600
|
+
* before a restart is exactly the stale level the recorder's reconcile bound
|
|
29601
|
+
* exists to end, and the provider re-derives the true level on activation
|
|
29602
|
+
* anyway. Nothing is lost by forgetting it; a lie is avoided.
|
|
29603
|
+
*
|
|
29604
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
29605
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
29606
|
+
*/
|
|
29607
|
+
durability: "session"
|
|
29608
|
+
};
|
|
29432
29609
|
/**
|
|
29433
29610
|
* scene-monitor — device-scoped reference-region state cap. An operator marks
|
|
29434
29611
|
* a rect ROI on a camera frame and names one or more states; the engine
|
|
@@ -36139,6 +36316,12 @@ Object.freeze({
|
|
|
36139
36316
|
addonId: null,
|
|
36140
36317
|
access: "view"
|
|
36141
36318
|
},
|
|
36319
|
+
"storage.listDrainProgress": {
|
|
36320
|
+
capName: "storage",
|
|
36321
|
+
capScope: "system",
|
|
36322
|
+
addonId: null,
|
|
36323
|
+
access: "view"
|
|
36324
|
+
},
|
|
36142
36325
|
"storage.listLocationDeclarations": {
|
|
36143
36326
|
capName: "storage",
|
|
36144
36327
|
capScope: "system",
|
|
@@ -36283,6 +36466,12 @@ Object.freeze({
|
|
|
36283
36466
|
addonId: null,
|
|
36284
36467
|
access: "view"
|
|
36285
36468
|
},
|
|
36469
|
+
"storageOccupancy.getOccupancy": {
|
|
36470
|
+
capName: "storage-occupancy",
|
|
36471
|
+
capScope: "system",
|
|
36472
|
+
addonId: null,
|
|
36473
|
+
access: "view"
|
|
36474
|
+
},
|
|
36286
36475
|
"storageProvider.abortUpload": {
|
|
36287
36476
|
capName: "storage-provider",
|
|
36288
36477
|
capScope: "system",
|
|
@@ -39892,702 +40081,113 @@ function deviceBackendToFormat(backend) {
|
|
|
39892
40081
|
return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
|
|
39893
40082
|
}
|
|
39894
40083
|
//#endregion
|
|
39895
|
-
//#region src/
|
|
40084
|
+
//#region src/audio-chunk-poller.ts
|
|
39896
40085
|
/**
|
|
39897
|
-
*
|
|
39898
|
-
*
|
|
40086
|
+
* `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
|
|
40087
|
+
* plane (Phase 5 / D9).
|
|
39899
40088
|
*
|
|
39900
|
-
*
|
|
39901
|
-
*
|
|
39902
|
-
*
|
|
39903
|
-
*
|
|
39904
|
-
* and the detection-pipeline are separate addons; only tRPC crosses the
|
|
39905
|
-
* boundary). Keep this in sync with `default-detection-model.ts` /
|
|
39906
|
-
* `step-definitions.ts` if the executor's defaults change.
|
|
40089
|
+
* Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
|
|
40090
|
+
* path. A live callback cannot cross a process boundary; once the `pipeline`
|
|
40091
|
+
* group is dissolved (Task 8) the orchestrator runs in a different process
|
|
40092
|
+
* from the broker, so audio delivery must go over tRPC.
|
|
39907
40093
|
*
|
|
39908
|
-
* The
|
|
39909
|
-
* - `yolov9m-320-int8` — Intel NPU + iGPU (yolo26 does NOT compile on the NPU)
|
|
39910
|
-
* - `yolov9m-320` — Apple ANE (CoreML)
|
|
39911
|
-
* - `ssd-mobilenet-v2-coco-edgetpu` — Coral USB Edge TPU (tflite)
|
|
39912
|
-
* - `yolo26n` — CPU / CUDA (the object-detection step's universal
|
|
39913
|
-
* nano default)
|
|
40094
|
+
* The consumer:
|
|
39914
40095
|
*
|
|
39915
|
-
*
|
|
39916
|
-
*
|
|
39917
|
-
*
|
|
39918
|
-
|
|
39919
|
-
|
|
39920
|
-
*
|
|
39921
|
-
*
|
|
39922
|
-
* cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
|
|
39923
|
-
* import is forbidden — this is the same duplication rationale as the model
|
|
39924
|
-
* defaults above).
|
|
39925
|
-
*/
|
|
39926
|
-
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
39927
|
-
/**
|
|
39928
|
-
* Build the camera-root capability predicate for a node from its live catalog:
|
|
39929
|
-
* `format → canHostCameraRoot`. A format can host a camera root iff the
|
|
39930
|
-
* catalog lists at least one object-detection model with a build for that
|
|
39931
|
-
* format — byte-for-byte the resolver's per-device skip-gate test for the root
|
|
39932
|
-
* step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
|
|
39933
|
-
* would ACTUALLY provision on it.
|
|
40096
|
+
* 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
|
|
40097
|
+
* registers a per-subscription bounded FIFO queue and returns a
|
|
40098
|
+
* `subscriptionId`;
|
|
40099
|
+
* 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
|
|
40100
|
+
* interval, draining `DecodedAudioChunk`s in FIFO arrival order;
|
|
40101
|
+
* 3. feeds each chunk to its downstream audio logic;
|
|
40102
|
+
* 4. on teardown, `unsubscribeAudioChunks`.
|
|
39934
40103
|
*
|
|
39935
|
-
*
|
|
39936
|
-
*
|
|
39937
|
-
*
|
|
40104
|
+
* Audio is not latency-critical like video, and chunks arrive only ~every
|
|
40105
|
+
* 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
|
|
40106
|
+
* a small per-poll burst keeps latency low without busy-spinning. The
|
|
40107
|
+
* broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
|
|
40108
|
+
* loses a chunk.
|
|
40109
|
+
*
|
|
40110
|
+
* Boot-race tolerance: the broker for a given camStream may not be registered
|
|
40111
|
+
* yet when the orchestrator wires the subscription (provider addons publish
|
|
40112
|
+
* their cameraStreams asynchronously after their probe completes).
|
|
40113
|
+
* `subscribeAudioChunks` retries with exponential backoff (capped at
|
|
40114
|
+
* `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
|
|
40115
|
+
* teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
|
|
40116
|
+
* shape so video and audio plumbing self-heal identically.
|
|
39938
40117
|
*/
|
|
39939
|
-
|
|
39940
|
-
|
|
39941
|
-
|
|
39942
|
-
|
|
39943
|
-
}
|
|
39944
|
-
return () => true;
|
|
39945
|
-
}
|
|
39946
|
-
/** Split a deviceKey (`<backend>:<device>`, or bare `cpu`) into its parts + format.
|
|
39947
|
-
* Format comes from the shared {@link deviceBackendToFormat} SSOT (`@camstack/types`)
|
|
39948
|
-
* — the previously-local `BACKEND_FORMAT` copy is gone (R3/node-F2). Used only for
|
|
39949
|
-
* STORED-ONLY keys (a configured device the live probe didn't return); a probed
|
|
39950
|
-
* device carries its own honest `format` from the descriptor. */
|
|
39951
|
-
function parseDeviceKey(deviceKey) {
|
|
39952
|
-
const colon = deviceKey.indexOf(":");
|
|
39953
|
-
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
39954
|
-
return {
|
|
39955
|
-
backend,
|
|
39956
|
-
device: colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey,
|
|
39957
|
-
format: deviceBackendToFormat(backend)
|
|
39958
|
-
};
|
|
39959
|
-
}
|
|
40118
|
+
/** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
|
|
40119
|
+
var POLL_INTERVAL_MS$1 = 200;
|
|
40120
|
+
/** How many chunks to drain per poll — a small burst absorbs jitter. */
|
|
40121
|
+
var PULL_MAX_COUNT = 8;
|
|
39960
40122
|
/**
|
|
39961
|
-
*
|
|
39962
|
-
*
|
|
39963
|
-
*
|
|
39964
|
-
*
|
|
40123
|
+
* Consecutive pull failures before we attempt to re-subscribe. A single failed
|
|
40124
|
+
* poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
|
|
40125
|
+
* sustained failure means the broker child restarted and dropped our
|
|
40126
|
+
* subscription, so we re-establish it.
|
|
39965
40127
|
*/
|
|
39966
|
-
|
|
39967
|
-
|
|
39968
|
-
|
|
39969
|
-
|
|
39970
|
-
|
|
39971
|
-
}
|
|
39972
|
-
if (backend === "edgetpu") return "ssd-mobilenet-v2-coco-edgetpu";
|
|
39973
|
-
if (backend === "coreml") return "yolov9m-320";
|
|
39974
|
-
return "yolo26n";
|
|
39975
|
-
}
|
|
40128
|
+
var RESUBSCRIBE_AFTER_FAILURES = 2;
|
|
40129
|
+
/** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
|
|
40130
|
+
var RESUBSCRIBE_THROTTLE_TICKS = 5;
|
|
40131
|
+
/** First subscribe-retry delay, doubled on every subsequent failure. */
|
|
40132
|
+
var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
|
|
39976
40133
|
/**
|
|
39977
|
-
*
|
|
39978
|
-
*
|
|
39979
|
-
*
|
|
39980
|
-
* device. `enabledAvailableKeys` is the effective enabled∧available set (from
|
|
39981
|
-
* `mergeInferenceDevices(probe, submitted)`) so an absent/unplugged/disabled
|
|
39982
|
-
* target is rejected honestly (an operator can't route a step onto a dead pool).
|
|
39983
|
-
* Returns the FIRST human-readable error, or `null` when every override is
|
|
39984
|
-
* valid. Pure + deterministic.
|
|
40134
|
+
* Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
|
|
40135
|
+
* enough to recover within a single reconcile of the orchestrator and slow
|
|
40136
|
+
* enough that a misconfigured camStream costs ~12 op/min, not 5/s.
|
|
39985
40137
|
*/
|
|
39986
|
-
|
|
39987
|
-
for (const [deviceKey, entry] of Object.entries(inferenceDevices)) for (const [stepId, step] of Object.entries(entry.steps ?? {})) {
|
|
39988
|
-
const target = step.jumpDeviceKey;
|
|
39989
|
-
if (target === void 0) continue;
|
|
39990
|
-
if (target === deviceKey) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey pointing at its own device`;
|
|
39991
|
-
if (!enabledAvailableKeys.has(target)) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey "${target}" that is not an enabled, available device on this node`;
|
|
39992
|
-
}
|
|
39993
|
-
return null;
|
|
39994
|
-
}
|
|
40138
|
+
var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
|
|
39995
40139
|
/**
|
|
39996
|
-
*
|
|
39997
|
-
*
|
|
39998
|
-
*
|
|
39999
|
-
*
|
|
40000
|
-
*
|
|
40001
|
-
*
|
|
40002
|
-
*
|
|
40003
|
-
*
|
|
40004
|
-
* NPU/iGPU/ANE. CPU stays the always-available FALLBACK (a node with no
|
|
40005
|
-
* eligible accelerator leaves `deviceKey` unset → the runner's default
|
|
40006
|
-
* pool, which is CPU), not a balanced target — matching the spec's "no
|
|
40007
|
-
* device eligible → fall back to CPU".
|
|
40008
|
-
* - **Coral Edge TPU (`edgetpu`)**: the standing rule since the Coral
|
|
40009
|
-
* executor landed is that it surfaces as selectable but is NEVER
|
|
40010
|
-
* auto-picked — it runs a DIFFERENT, weaker model family (tflite SSD
|
|
40011
|
-
* MobileNet, not the YOLO the other accelerators run), so silently
|
|
40012
|
-
* enrolling a plugged-in Coral changes detection QUALITY, not just
|
|
40013
|
-
* placement. The opt-OUT default did exactly that on 2026-08-01: a hub
|
|
40014
|
-
* Coral nobody enabled entered the session rotation and camera 615 spent
|
|
40015
|
-
* hours at 2.4fps failing tflite model resolution. An operator who wants
|
|
40016
|
-
* the Coral balanced opts it in explicitly (`enabled: true`).
|
|
40017
|
-
*
|
|
40018
|
-
* So: an NPU/iGPU/ANE accelerator with NO stored entry is `enabled:true`; a
|
|
40019
|
-
* CPU or edgetpu device with no stored entry is `enabled:false`; an explicit
|
|
40020
|
-
* stored `enabled` always wins (an operator can opt CPU/Coral in, or an
|
|
40021
|
-
* accelerator out). A stored-only key (configured but the probe did not
|
|
40022
|
-
* return it — removed/unplugged HW) keeps its stored `enabled` and surfaces
|
|
40023
|
-
* as `available:false`, so the UI still shows it.
|
|
40024
|
-
*
|
|
40025
|
-
* Pure + deterministic (sorted by key) — the single merge authority shared by
|
|
40026
|
-
* the `getNodeInferenceDevices` view and the dispatcher's eligible-device pick.
|
|
40140
|
+
* Attempts after which a still-failing subscribe escalates from the fast 5 s
|
|
40141
|
+
* ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
|
|
40142
|
+
* minute of fast retries — plenty for the boot races the 5 s ceiling exists
|
|
40143
|
+
* for. A broker that is STILL absent after that is a long-lived condition
|
|
40144
|
+
* (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
|
|
40145
|
+
* to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
|
|
40146
|
+
* churn. The slow loop stays alive so audio still recovers automatically
|
|
40147
|
+
* (≤60 s) once the camera is re-enabled.
|
|
40027
40148
|
*/
|
|
40028
|
-
|
|
40029
|
-
|
|
40030
|
-
const keys = new Set([...probedByKey.keys(), ...Object.keys(stored)]);
|
|
40031
|
-
const out = [];
|
|
40032
|
-
for (const key of Array.from(keys).toSorted()) {
|
|
40033
|
-
const descriptor = probedByKey.get(key);
|
|
40034
|
-
const opt = stored[key];
|
|
40035
|
-
const parsed = descriptor ?? parseDeviceKey(key);
|
|
40036
|
-
const weight = opt?.weight !== void 0 && opt.weight > 0 ? opt.weight : 1;
|
|
40037
|
-
const autoDefault = parsed.backend !== "cpu" && parsed.backend !== "edgetpu";
|
|
40038
|
-
out.push({
|
|
40039
|
-
key,
|
|
40040
|
-
backend: parsed.backend,
|
|
40041
|
-
device: parsed.device,
|
|
40042
|
-
format: parsed.format,
|
|
40043
|
-
available: descriptor?.available ?? false,
|
|
40044
|
-
enabled: opt?.enabled ?? autoDefault,
|
|
40045
|
-
weight,
|
|
40046
|
-
maxSessions: opt?.maxSessions ?? null,
|
|
40047
|
-
defaultModelId: defaultModelIdForDevice(key),
|
|
40048
|
-
...opt?.steps && Object.keys(opt.steps).length > 0 ? { steps: { ...opt.steps } } : {}
|
|
40049
|
-
});
|
|
40050
|
-
}
|
|
40051
|
-
return out;
|
|
40052
|
-
}
|
|
40149
|
+
var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
|
|
40150
|
+
var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
|
|
40053
40151
|
/**
|
|
40054
|
-
*
|
|
40055
|
-
*
|
|
40056
|
-
*
|
|
40152
|
+
* Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
|
|
40153
|
+
*
|
|
40154
|
+
* Always resolves to a teardown closure — when the broker is not yet
|
|
40155
|
+
* registered the closure cancels the ongoing retry loop; when polling is
|
|
40156
|
+
* active it stops the loop and releases the broker subscription. Mirrors
|
|
40157
|
+
* `startFrameHandlePoller` so video and audio recover identically.
|
|
40057
40158
|
*/
|
|
40058
|
-
function
|
|
40059
|
-
const
|
|
40060
|
-
|
|
40061
|
-
|
|
40062
|
-
|
|
40063
|
-
|
|
40064
|
-
|
|
40065
|
-
|
|
40066
|
-
|
|
40067
|
-
|
|
40068
|
-
|
|
40069
|
-
|
|
40070
|
-
|
|
40071
|
-
const acceleratorServes = merged.some((d) => !isCpuFallback(d) && d.enabled && d.available && (!canRunRoot || canRunRoot(d.format)));
|
|
40072
|
-
for (const d of merged) {
|
|
40073
|
-
if (!d.enabled) {
|
|
40074
|
-
excluded.push({
|
|
40075
|
-
key: d.key,
|
|
40076
|
-
reason: "disabled",
|
|
40077
|
-
format: d.format
|
|
40078
|
-
});
|
|
40079
|
-
continue;
|
|
40080
|
-
}
|
|
40081
|
-
if (!d.available) {
|
|
40082
|
-
excluded.push({
|
|
40083
|
-
key: d.key,
|
|
40084
|
-
reason: "unavailable",
|
|
40085
|
-
format: d.format
|
|
40086
|
-
});
|
|
40087
|
-
continue;
|
|
40088
|
-
}
|
|
40089
|
-
if (isPoolUsable && !isPoolUsable(d.key)) {
|
|
40090
|
-
excluded.push({
|
|
40091
|
-
key: d.key,
|
|
40092
|
-
reason: "unavailable",
|
|
40093
|
-
format: d.format
|
|
40094
|
-
});
|
|
40095
|
-
continue;
|
|
40159
|
+
function startAudioChunkPoller(options) {
|
|
40160
|
+
const lifecycle = {
|
|
40161
|
+
stopped: false,
|
|
40162
|
+
retryTimer: void 0,
|
|
40163
|
+
pollTimer: void 0,
|
|
40164
|
+
activeSubscriptionId: null
|
|
40165
|
+
};
|
|
40166
|
+
const teardown = () => {
|
|
40167
|
+
if (lifecycle.stopped) return;
|
|
40168
|
+
lifecycle.stopped = true;
|
|
40169
|
+
if (lifecycle.retryTimer) {
|
|
40170
|
+
clearTimeout(lifecycle.retryTimer);
|
|
40171
|
+
lifecycle.retryTimer = void 0;
|
|
40096
40172
|
}
|
|
40097
|
-
if (
|
|
40098
|
-
|
|
40099
|
-
|
|
40100
|
-
reason: "cannot-host-camera-root",
|
|
40101
|
-
format: d.format
|
|
40102
|
-
});
|
|
40103
|
-
continue;
|
|
40173
|
+
if (lifecycle.pollTimer) {
|
|
40174
|
+
clearTimeout(lifecycle.pollTimer);
|
|
40175
|
+
lifecycle.pollTimer = void 0;
|
|
40104
40176
|
}
|
|
40105
|
-
|
|
40106
|
-
|
|
40107
|
-
|
|
40108
|
-
|
|
40109
|
-
|
|
40177
|
+
const subId = lifecycle.activeSubscriptionId;
|
|
40178
|
+
if (subId) {
|
|
40179
|
+
lifecycle.activeSubscriptionId = null;
|
|
40180
|
+
options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
|
|
40181
|
+
options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
|
|
40182
|
+
brokerId: options.brokerId,
|
|
40183
|
+
subscriptionId: subId,
|
|
40184
|
+
error: errMsg(err)
|
|
40185
|
+
} });
|
|
40110
40186
|
});
|
|
40111
|
-
continue;
|
|
40112
40187
|
}
|
|
40113
|
-
eligible[d.key] = d.weight;
|
|
40114
|
-
}
|
|
40115
|
-
return {
|
|
40116
|
-
eligible,
|
|
40117
|
-
excluded
|
|
40118
40188
|
};
|
|
40119
|
-
|
|
40120
|
-
|
|
40121
|
-
* Join the merged device rows with the eligibility verdict so the UI can NAME
|
|
40122
|
-
* why an accelerator is not in play instead of leaving the operator to deduce
|
|
40123
|
-
* it from `enabled`/`available`.
|
|
40124
|
-
*
|
|
40125
|
-
* Deduction is not possible for two of the four reasons — `accelerator-preferred`
|
|
40126
|
-
* is a node-WIDE rule (a CPU row reads `enabled:true, available:true` and still
|
|
40127
|
-
* never gets a session, D215) and `cannot-host-camera-root` needs the node's
|
|
40128
|
-
* model catalog. Both live in {@link resolveInferenceDeviceEligibility}, so this
|
|
40129
|
-
* function only transports its answer; it never re-derives one.
|
|
40130
|
-
*
|
|
40131
|
-
* Pure; preserves `merged`'s order (sorted by key) and every other field.
|
|
40132
|
-
*/
|
|
40133
|
-
function annotateInferenceDeviceExclusions(merged, eligibility) {
|
|
40134
|
-
const reasonByKey = new Map(eligibility.excluded.map((e) => [e.key, e.reason]));
|
|
40135
|
-
return merged.map((view) => ({
|
|
40136
|
-
...view,
|
|
40137
|
-
exclusion: reasonByKey.get(view.key) ?? null
|
|
40138
|
-
}));
|
|
40139
|
-
}
|
|
40140
|
-
function resolveNodeInferenceUsability(eligibility) {
|
|
40141
|
-
const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
|
|
40142
|
-
const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
|
|
40143
|
-
return {
|
|
40144
|
-
usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
|
|
40145
|
-
unavailableKeys,
|
|
40146
|
-
eligibleKeys
|
|
40147
|
-
};
|
|
40148
|
-
}
|
|
40149
|
-
/**
|
|
40150
|
-
* Step-tree device jump (phase 1): the attach-payload roster of a node's
|
|
40151
|
-
* enabled∧available inference devices with the balancer knobs (`weight`,
|
|
40152
|
-
* `maxSessions`) the runner uses to AUTO-jump an enrichment step off a device
|
|
40153
|
-
* whose format can't run it. Built from the SAME `eligible` (deviceKey→weight)
|
|
40154
|
-
* and `caps` (deviceKey→maxSessions) the dispatcher already computes, so the
|
|
40155
|
-
* roster the runner sees exactly matches the balancer's candidate set. Sorted
|
|
40156
|
-
* by key for determinism. Populated onto `RunnerCameraConfig.inferenceDevices`
|
|
40157
|
-
* ONLY when a `deviceKey` is elected and there are ≥2 entries.
|
|
40158
|
-
*/
|
|
40159
|
-
function buildInferenceDeviceRoster(eligible, caps) {
|
|
40160
|
-
return Object.entries(eligible).map(([deviceKey, weight]) => ({
|
|
40161
|
-
deviceKey,
|
|
40162
|
-
weight: weight > 0 ? weight : 1,
|
|
40163
|
-
maxSessions: caps[deviceKey] ?? null
|
|
40164
|
-
})).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
|
|
40165
|
-
}
|
|
40166
|
-
//#endregion
|
|
40167
|
-
//#region src/node-inference-usability-mirror.ts
|
|
40168
|
-
var NodeInferenceUsabilityMirror = class {
|
|
40169
|
-
state = /* @__PURE__ */ new Map();
|
|
40170
|
-
/**
|
|
40171
|
-
* Fold one observation in and report whether the caller should act.
|
|
40172
|
-
* Never throws.
|
|
40173
|
-
*/
|
|
40174
|
-
observe(nodeId, usable) {
|
|
40175
|
-
const prev = this.state.get(nodeId);
|
|
40176
|
-
if (usable) {
|
|
40177
|
-
this.state.set(nodeId, {
|
|
40178
|
-
usable: true,
|
|
40179
|
-
armed: false
|
|
40180
|
-
});
|
|
40181
|
-
return prev !== void 0 && !prev.usable ? "recovered" : null;
|
|
40182
|
-
}
|
|
40183
|
-
if (prev === void 0) {
|
|
40184
|
-
this.state.set(nodeId, {
|
|
40185
|
-
usable: true,
|
|
40186
|
-
armed: true
|
|
40187
|
-
});
|
|
40188
|
-
return null;
|
|
40189
|
-
}
|
|
40190
|
-
if (!prev.usable) {
|
|
40191
|
-
this.state.set(nodeId, {
|
|
40192
|
-
usable: false,
|
|
40193
|
-
armed: true
|
|
40194
|
-
});
|
|
40195
|
-
return null;
|
|
40196
|
-
}
|
|
40197
|
-
if (!prev.armed) {
|
|
40198
|
-
this.state.set(nodeId, {
|
|
40199
|
-
usable: true,
|
|
40200
|
-
armed: true
|
|
40201
|
-
});
|
|
40202
|
-
return null;
|
|
40203
|
-
}
|
|
40204
|
-
this.state.set(nodeId, {
|
|
40205
|
-
usable: false,
|
|
40206
|
-
armed: true
|
|
40207
|
-
});
|
|
40208
|
-
return "became-unusable";
|
|
40209
|
-
}
|
|
40210
|
-
/** Can this node be given cameras? Unknown nodes answer YES. */
|
|
40211
|
-
isUsable(nodeId) {
|
|
40212
|
-
return this.state.get(nodeId)?.usable ?? true;
|
|
40213
|
-
}
|
|
40214
|
-
/** Nodes currently excluded — for the placement log and diagnostics. */
|
|
40215
|
-
unusableNodeIds() {
|
|
40216
|
-
const out = [];
|
|
40217
|
-
for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
|
|
40218
|
-
return out.toSorted();
|
|
40219
|
-
}
|
|
40220
|
-
forget(nodeId) {
|
|
40221
|
-
this.state.delete(nodeId);
|
|
40222
|
-
}
|
|
40223
|
-
reset() {
|
|
40224
|
-
this.state.clear();
|
|
40225
|
-
}
|
|
40226
|
-
};
|
|
40227
|
-
//#endregion
|
|
40228
|
-
//#region src/inference-device-usability-mirror.ts
|
|
40229
|
-
/**
|
|
40230
|
-
* InferenceDeviceUsabilityMirror — "can this NODE's THIS DEVICE still infer?",
|
|
40231
|
-
* kept off the placement path.
|
|
40232
|
-
*
|
|
40233
|
-
* The per-`(nodeId, deviceKey)` twin of {@link NodeInferenceUsabilityMirror},
|
|
40234
|
-
* and deliberately not a second mechanism: it composes the key and delegates
|
|
40235
|
-
* every decision to that class, so the arm/apply reluctance D49 pinned lives in
|
|
40236
|
-
* exactly one implementation and cannot drift between the node tier and the
|
|
40237
|
-
* device tier.
|
|
40238
|
-
*
|
|
40239
|
-
* ## Why this tier had to exist
|
|
40240
|
-
*
|
|
40241
|
-
* The node tier already answers "does this node have ANY usable accelerator".
|
|
40242
|
-
* On 2026-08-25 the hub's answer was, correctly, yes — its NPU was healthy the
|
|
40243
|
-
* whole time. What died was one DEVICE, `openvino:gpu`, and nothing anywhere
|
|
40244
|
-
* asked that question: the per-dispatch capability gate is keyed on model
|
|
40245
|
-
* FORMAT and `openvino:gpu` and `openvino:npu` share the format `openvino`, so
|
|
40246
|
-
* it is blind between them by construction. The balancer kept rotating cameras
|
|
40247
|
-
* onto the dead pool — 20 sessions in 20 minutes, every one `tieBreak:
|
|
40248
|
-
* "rotation"` — for 31 hours.
|
|
40249
|
-
*
|
|
40250
|
-
* ## Why a mirror and not the event
|
|
40251
|
-
*
|
|
40252
|
-
* D49, verbatim: *a one-shot event never gates on a fallible read*. The gate is
|
|
40253
|
-
* this in-memory mirror, refreshed off the event path by the same
|
|
40254
|
-
* `resolveEligibleInferenceDevices` the dispatcher already runs (plus the
|
|
40255
|
-
* session controller's background refresher). The consequences that buys:
|
|
40256
|
-
*
|
|
40257
|
-
* - **A read that fails changes nothing.** The caller folds in an observation
|
|
40258
|
-
* only when it HAS one; an unreachable node, a version-skewed executor or a
|
|
40259
|
-
* rejected RPC never reaches {@link observe}, so the previous verdict
|
|
40260
|
-
* stands. This is the whole reason the health read is specified as
|
|
40261
|
-
* "synchronous over in-memory state, never throws for its own reasons": an
|
|
40262
|
-
* empty answer must mean *nothing is refused*, not *I could not tell*.
|
|
40263
|
-
* - **Excluding a healthy device needs a second, consecutive read.** Exclusion
|
|
40264
|
-
* is the direction that DESTROYS work — it strands an accelerator that may
|
|
40265
|
-
* be perfectly fine — so one bad observation only ARMS.
|
|
40266
|
-
* - **Re-admitting is immediate and unconditional.** One good observation puts
|
|
40267
|
-
* the device straight back. Being slow to exclude costs some wasted
|
|
40268
|
-
* inference attempts; being slow to re-admit costs an idle accelerator and a
|
|
40269
|
-
* node that looks broken.
|
|
40270
|
-
*/
|
|
40271
|
-
/**
|
|
40272
|
-
* NUL — impossible in both a Moleculer node id and a `<backend>:<device>` key,
|
|
40273
|
-
* so the composite key can never be ambiguous. A separator that CAN occur in
|
|
40274
|
-
* either half makes two distinct pairs collide, and a collision here silently
|
|
40275
|
-
* excludes an accelerator nobody reported.
|
|
40276
|
-
*/
|
|
40277
|
-
var SEPARATOR = "\0";
|
|
40278
|
-
var InferenceDeviceUsabilityMirror = class {
|
|
40279
|
-
/** The one implementation of the arm/apply state machine (D49). */
|
|
40280
|
-
mirror = new NodeInferenceUsabilityMirror();
|
|
40281
|
-
/**
|
|
40282
|
-
* Fold one observation in and report whether the caller should act.
|
|
40283
|
-
* Never throws.
|
|
40284
|
-
*/
|
|
40285
|
-
observe(nodeId, deviceKey, usable) {
|
|
40286
|
-
return this.mirror.observe(`${nodeId}${SEPARATOR}${deviceKey}`, usable);
|
|
40287
|
-
}
|
|
40288
|
-
/** Can the balancer put a session on this device? Unknown pairs answer YES. */
|
|
40289
|
-
isUsable(nodeId, deviceKey) {
|
|
40290
|
-
return this.mirror.isUsable(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
40291
|
-
}
|
|
40292
|
-
/** Pairs currently excluded — for the placement log and diagnostics. */
|
|
40293
|
-
unusableDevices() {
|
|
40294
|
-
return this.mirror.unusableNodeIds().map((composite) => {
|
|
40295
|
-
const at = composite.indexOf(SEPARATOR);
|
|
40296
|
-
return {
|
|
40297
|
-
nodeId: composite.slice(0, at),
|
|
40298
|
-
deviceKey: composite.slice(at + 1)
|
|
40299
|
-
};
|
|
40300
|
-
});
|
|
40301
|
-
}
|
|
40302
|
-
/** The excluded device keys on ONE node. */
|
|
40303
|
-
unusableDeviceKeys(nodeId) {
|
|
40304
|
-
return this.unusableDevices().filter((d) => d.nodeId === nodeId).map((d) => d.deviceKey);
|
|
40305
|
-
}
|
|
40306
|
-
forget(nodeId, deviceKey) {
|
|
40307
|
-
this.mirror.forget(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
40308
|
-
}
|
|
40309
|
-
reset() {
|
|
40310
|
-
this.mirror.reset();
|
|
40311
|
-
}
|
|
40312
|
-
};
|
|
40313
|
-
/**
|
|
40314
|
-
* Fold ONE node's health answer into the mirror and return what changed.
|
|
40315
|
-
*
|
|
40316
|
-
* This is the whole reading discipline, in one place, because both halves of it
|
|
40317
|
-
* are easy to get subtly wrong and neither failure is visible in a log:
|
|
40318
|
-
*
|
|
40319
|
-
* - **`unhealthy === null` — the read FAILED — changes nothing.** Not a single
|
|
40320
|
-
* entry is touched. An unreachable node, a version-skewed executor or a
|
|
40321
|
-
* rejected RPC must be distinguishable from "asked, nothing is refused", or
|
|
40322
|
-
* a flaky link silently re-admits a dead accelerator (D49).
|
|
40323
|
-
* - **Every device the node HAS is observed**, not merely the refused ones.
|
|
40324
|
-
* The first draft observed `refused ∪ already-excluded`, which omits exactly
|
|
40325
|
-
* the devices the mirror has ARMED — so their disarming good read never
|
|
40326
|
-
* arrived and two bad reads an HOUR apart, with a hundred healthy ones
|
|
40327
|
-
* between them, excluded a working accelerator. "Consecutive" is only a
|
|
40328
|
-
* property if the good observations are delivered.
|
|
40329
|
-
*
|
|
40330
|
-
* Pure with respect to everything except `mirror`, and never throws — it is
|
|
40331
|
-
* called from the dispatcher's own read path.
|
|
40332
|
-
*/
|
|
40333
|
-
function foldInferenceDeviceHealth(mirror, nodeId, unhealthy, present) {
|
|
40334
|
-
if (unhealthy === null) return [];
|
|
40335
|
-
const refused = new Set(unhealthy);
|
|
40336
|
-
const observed = new Set([
|
|
40337
|
-
...present,
|
|
40338
|
-
...refused,
|
|
40339
|
-
...mirror.unusableDeviceKeys(nodeId)
|
|
40340
|
-
]);
|
|
40341
|
-
const changes = [];
|
|
40342
|
-
for (const deviceKey of observed) {
|
|
40343
|
-
const transition = mirror.observe(nodeId, deviceKey, !refused.has(deviceKey));
|
|
40344
|
-
if (transition !== null) changes.push({
|
|
40345
|
-
deviceKey,
|
|
40346
|
-
transition
|
|
40347
|
-
});
|
|
40348
|
-
}
|
|
40349
|
-
return changes;
|
|
40350
|
-
}
|
|
40351
|
-
//#endregion
|
|
40352
|
-
//#region src/orchestrator-types.ts
|
|
40353
|
-
var PHASE_MODE_VALUES = new Set([
|
|
40354
|
-
"disabled",
|
|
40355
|
-
"always-on",
|
|
40356
|
-
"on-motion"
|
|
40357
|
-
]);
|
|
40358
|
-
function isPipelinePhaseMode(v) {
|
|
40359
|
-
return PHASE_MODE_VALUES.has(v);
|
|
40360
|
-
}
|
|
40361
|
-
/**
|
|
40362
|
-
* Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
|
|
40363
|
-
* that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
|
|
40364
|
-
* `reconcileDispatch` is additive-only and never revisits these, so a slow
|
|
40365
|
-
* safety-net timer + event-driven debounce triggers recover them.
|
|
40366
|
-
*/
|
|
40367
|
-
var PENDING_RETRY_INTERVAL_MS = 6e4;
|
|
40368
|
-
/** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
|
|
40369
|
-
var PENDING_RETRY_DEBOUNCE_MS = 2e3;
|
|
40370
|
-
/**
|
|
40371
|
-
* Periodic auto-rebalance sweep. New attaches are already load-balanced at
|
|
40372
|
-
* dispatch time; this corrects DRIFT that accumulates over time (uneven
|
|
40373
|
-
* detach, a node returning online, a weight change) so the steady-state
|
|
40374
|
-
* distribution tracks the per-node weights. Only runs with ≥2 enabled detect
|
|
40375
|
-
* nodes, and migrates under hysteresis so a balanced cluster is a no-op.
|
|
40376
|
-
*/
|
|
40377
|
-
var AUTO_REBALANCE_INTERVAL_MS = 6e4;
|
|
40378
|
-
/**
|
|
40379
|
-
* Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
|
|
40380
|
-
* migrate a camera only when its target node is at least this much less loaded
|
|
40381
|
-
* than its current node. > 1 so equalizing a single-camera gap (which would
|
|
40382
|
-
* only reverse the imbalance) is skipped — prevents periodic churn.
|
|
40383
|
-
*/
|
|
40384
|
-
var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
|
|
40385
|
-
var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
|
|
40386
|
-
/**
|
|
40387
|
-
* Device-details keys routed through the orchestrator's pipeline
|
|
40388
|
-
* settings writer instead of the device orchestration store. The
|
|
40389
|
-
* `cameraPipeline` key carries the full `CameraPipelineConfig`
|
|
40390
|
-
* emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
|
|
40391
|
-
*/
|
|
40392
|
-
var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
|
|
40393
|
-
var DEFAULT_FAILOVER_POLICY = {
|
|
40394
|
-
onDisconnect: "migrate",
|
|
40395
|
-
pinnedOnDisconnect: "leave-pinned",
|
|
40396
|
-
onReconnect: "restore"
|
|
40397
|
-
};
|
|
40398
|
-
/**
|
|
40399
|
-
* Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
|
|
40400
|
-
*
|
|
40401
|
-
* The orchestrator's cap surface is the contract for all runtime traffic
|
|
40402
|
-
* (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
|
|
40403
|
-
* catalog is reserved for read-only diagnostics that are intentionally
|
|
40404
|
-
* outside the cap — they expose internal state (balancer caches, enabledNodes
|
|
40405
|
-
* set, active detection count) that is useful for admin tooling but does not
|
|
40406
|
-
* belong on the capability contract.
|
|
40407
|
-
*/
|
|
40408
|
-
var OrchestratorDiagnosticsSchema = object({
|
|
40409
|
-
localNodeId: string(),
|
|
40410
|
-
knownRunnerNodes: array(string()),
|
|
40411
|
-
cachedAgentLoadNodeIds: array(string()),
|
|
40412
|
-
enabledNodes: array(string()),
|
|
40413
|
-
enabledDecoderNodes: array(string()),
|
|
40414
|
-
enabledAudioNodes: array(string()),
|
|
40415
|
-
enabledIngestNodes: array(string()),
|
|
40416
|
-
clusterRoles: object({
|
|
40417
|
-
ingestNode: string(),
|
|
40418
|
-
audioNode: string(),
|
|
40419
|
-
motionNode: string()
|
|
40420
|
-
}),
|
|
40421
|
-
assignedDeviceCount: number().int().min(0),
|
|
40422
|
-
cameraConfigCount: number().int().min(0),
|
|
40423
|
-
activeDetectionCount: number().int().min(0)
|
|
40424
|
-
});
|
|
40425
|
-
/**
|
|
40426
|
-
* The node-stress long-term-statistics read surface.
|
|
40427
|
-
*
|
|
40428
|
-
* A custom action rather than a cap method, matching how the orchestrator
|
|
40429
|
-
* already serves `dumpState`: this is a hub-local read over a table the hub
|
|
40430
|
-
* owns, and it ships with one `camstack deploy` instead of a release train.
|
|
40431
|
-
* The MEAN is derived here and returned alongside the addable `sum`/`samples`
|
|
40432
|
-
* — a chart wants the first, a re-bucketing caller wants the second, and a
|
|
40433
|
-
* stored mean is a field that can disagree with both.
|
|
40434
|
-
*/
|
|
40435
|
-
var NodeStressStatsInputSchema = object({
|
|
40436
|
-
/** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
|
|
40437
|
-
series: string().optional(),
|
|
40438
|
-
/** A node id. Omit for every node. */
|
|
40439
|
-
subject: string().optional(),
|
|
40440
|
-
/** Inclusive bucket-start bounds, ms. */
|
|
40441
|
-
from: number().int().optional(),
|
|
40442
|
-
to: number().int().optional(),
|
|
40443
|
-
limit: number().int().positive().max(5e3).optional()
|
|
40444
|
-
});
|
|
40445
|
-
var NodeStressStatsRowSchema = object({
|
|
40446
|
-
subject: string(),
|
|
40447
|
-
series: string(),
|
|
40448
|
-
scope: string(),
|
|
40449
|
-
bucketStart: number(),
|
|
40450
|
-
samples: number(),
|
|
40451
|
-
sum: number(),
|
|
40452
|
-
mean: number(),
|
|
40453
|
-
min: number(),
|
|
40454
|
-
max: number()
|
|
40455
|
-
});
|
|
40456
|
-
var NodeStressStatsOutputSchema = object({
|
|
40457
|
-
rows: array(NodeStressStatsRowSchema).readonly(),
|
|
40458
|
-
/** Buckets still accumulating — "is it running" answerable at once, rather
|
|
40459
|
-
* than after five minutes of indistinguishable silence. */
|
|
40460
|
-
open: array(NodeStressStatsRowSchema).readonly(),
|
|
40461
|
-
/** The durable failover history the anti-flap guards read, newest first.
|
|
40462
|
-
* Exposed for the same reason the heartbeat exists: "nothing moved" has to
|
|
40463
|
-
* be distinguishable from "nothing is watching". */
|
|
40464
|
-
moves: array(object({
|
|
40465
|
-
deviceId: number(),
|
|
40466
|
-
fromNodeId: string(),
|
|
40467
|
-
at: number()
|
|
40468
|
-
})).readonly()
|
|
40469
|
-
});
|
|
40470
|
-
var pipelineOrchestratorActions = defineCustomActions({
|
|
40471
|
-
dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
|
|
40472
|
-
nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
|
|
40473
|
-
});
|
|
40474
|
-
/**
|
|
40475
|
-
* Sentinel returned by `buildDetectionConfig` when the profile-slot READ
|
|
40476
|
-
* itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
|
|
40477
|
-
* while the stream-broker is (re)starting) — as opposed to `null`, which
|
|
40478
|
-
* means "genuinely no assigned slot / not configured". Callers MUST treat
|
|
40479
|
-
* this differently from `null`: never stop active detection on a transient
|
|
40480
|
-
* read failure (the slots almost certainly still exist), and schedule a retry.
|
|
40481
|
-
*/
|
|
40482
|
-
var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
|
|
40483
|
-
//#endregion
|
|
40484
|
-
//#region src/audio-chunk-poller.ts
|
|
40485
|
-
/**
|
|
40486
|
-
* `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
|
|
40487
|
-
* plane (Phase 5 / D9).
|
|
40488
|
-
*
|
|
40489
|
-
* Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
|
|
40490
|
-
* path. A live callback cannot cross a process boundary; once the `pipeline`
|
|
40491
|
-
* group is dissolved (Task 8) the orchestrator runs in a different process
|
|
40492
|
-
* from the broker, so audio delivery must go over tRPC.
|
|
40493
|
-
*
|
|
40494
|
-
* The consumer:
|
|
40495
|
-
*
|
|
40496
|
-
* 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
|
|
40497
|
-
* registers a per-subscription bounded FIFO queue and returns a
|
|
40498
|
-
* `subscriptionId`;
|
|
40499
|
-
* 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
|
|
40500
|
-
* interval, draining `DecodedAudioChunk`s in FIFO arrival order;
|
|
40501
|
-
* 3. feeds each chunk to its downstream audio logic;
|
|
40502
|
-
* 4. on teardown, `unsubscribeAudioChunks`.
|
|
40503
|
-
*
|
|
40504
|
-
* Audio is not latency-critical like video, and chunks arrive only ~every
|
|
40505
|
-
* 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
|
|
40506
|
-
* a small per-poll burst keeps latency low without busy-spinning. The
|
|
40507
|
-
* broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
|
|
40508
|
-
* loses a chunk.
|
|
40509
|
-
*
|
|
40510
|
-
* Boot-race tolerance: the broker for a given camStream may not be registered
|
|
40511
|
-
* yet when the orchestrator wires the subscription (provider addons publish
|
|
40512
|
-
* their cameraStreams asynchronously after their probe completes).
|
|
40513
|
-
* `subscribeAudioChunks` retries with exponential backoff (capped at
|
|
40514
|
-
* `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
|
|
40515
|
-
* teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
|
|
40516
|
-
* shape so video and audio plumbing self-heal identically.
|
|
40517
|
-
*/
|
|
40518
|
-
/** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
|
|
40519
|
-
var POLL_INTERVAL_MS$1 = 200;
|
|
40520
|
-
/** How many chunks to drain per poll — a small burst absorbs jitter. */
|
|
40521
|
-
var PULL_MAX_COUNT = 8;
|
|
40522
|
-
/**
|
|
40523
|
-
* Consecutive pull failures before we attempt to re-subscribe. A single failed
|
|
40524
|
-
* poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
|
|
40525
|
-
* sustained failure means the broker child restarted and dropped our
|
|
40526
|
-
* subscription, so we re-establish it.
|
|
40527
|
-
*/
|
|
40528
|
-
var RESUBSCRIBE_AFTER_FAILURES = 2;
|
|
40529
|
-
/** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
|
|
40530
|
-
var RESUBSCRIBE_THROTTLE_TICKS = 5;
|
|
40531
|
-
/** First subscribe-retry delay, doubled on every subsequent failure. */
|
|
40532
|
-
var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
|
|
40533
|
-
/**
|
|
40534
|
-
* Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
|
|
40535
|
-
* enough to recover within a single reconcile of the orchestrator and slow
|
|
40536
|
-
* enough that a misconfigured camStream costs ~12 op/min, not 5/s.
|
|
40537
|
-
*/
|
|
40538
|
-
var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
|
|
40539
|
-
/**
|
|
40540
|
-
* Attempts after which a still-failing subscribe escalates from the fast 5 s
|
|
40541
|
-
* ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
|
|
40542
|
-
* minute of fast retries — plenty for the boot races the 5 s ceiling exists
|
|
40543
|
-
* for. A broker that is STILL absent after that is a long-lived condition
|
|
40544
|
-
* (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
|
|
40545
|
-
* to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
|
|
40546
|
-
* churn. The slow loop stays alive so audio still recovers automatically
|
|
40547
|
-
* (≤60 s) once the camera is re-enabled.
|
|
40548
|
-
*/
|
|
40549
|
-
var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
|
|
40550
|
-
var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
|
|
40551
|
-
/**
|
|
40552
|
-
* Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
|
|
40553
|
-
*
|
|
40554
|
-
* Always resolves to a teardown closure — when the broker is not yet
|
|
40555
|
-
* registered the closure cancels the ongoing retry loop; when polling is
|
|
40556
|
-
* active it stops the loop and releases the broker subscription. Mirrors
|
|
40557
|
-
* `startFrameHandlePoller` so video and audio recover identically.
|
|
40558
|
-
*/
|
|
40559
|
-
function startAudioChunkPoller(options) {
|
|
40560
|
-
const lifecycle = {
|
|
40561
|
-
stopped: false,
|
|
40562
|
-
retryTimer: void 0,
|
|
40563
|
-
pollTimer: void 0,
|
|
40564
|
-
activeSubscriptionId: null
|
|
40565
|
-
};
|
|
40566
|
-
const teardown = () => {
|
|
40567
|
-
if (lifecycle.stopped) return;
|
|
40568
|
-
lifecycle.stopped = true;
|
|
40569
|
-
if (lifecycle.retryTimer) {
|
|
40570
|
-
clearTimeout(lifecycle.retryTimer);
|
|
40571
|
-
lifecycle.retryTimer = void 0;
|
|
40572
|
-
}
|
|
40573
|
-
if (lifecycle.pollTimer) {
|
|
40574
|
-
clearTimeout(lifecycle.pollTimer);
|
|
40575
|
-
lifecycle.pollTimer = void 0;
|
|
40576
|
-
}
|
|
40577
|
-
const subId = lifecycle.activeSubscriptionId;
|
|
40578
|
-
if (subId) {
|
|
40579
|
-
lifecycle.activeSubscriptionId = null;
|
|
40580
|
-
options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
|
|
40581
|
-
options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
|
|
40582
|
-
brokerId: options.brokerId,
|
|
40583
|
-
subscriptionId: subId,
|
|
40584
|
-
error: errMsg(err)
|
|
40585
|
-
} });
|
|
40586
|
-
});
|
|
40587
|
-
}
|
|
40588
|
-
};
|
|
40589
|
-
subscribeWithRetry(options, lifecycle);
|
|
40590
|
-
return teardown;
|
|
40189
|
+
subscribeWithRetry(options, lifecycle);
|
|
40190
|
+
return teardown;
|
|
40591
40191
|
}
|
|
40592
40192
|
/**
|
|
40593
40193
|
* Run the subscribe → poll handshake with exponential backoff on subscribe
|
|
@@ -40739,47 +40339,179 @@ function balanceAudio(input) {
|
|
|
40739
40339
|
};
|
|
40740
40340
|
}
|
|
40741
40341
|
//#endregion
|
|
40742
|
-
//#region src/
|
|
40743
|
-
var
|
|
40744
|
-
|
|
40745
|
-
|
|
40746
|
-
|
|
40747
|
-
|
|
40748
|
-
|
|
40749
|
-
|
|
40750
|
-
|
|
40751
|
-
|
|
40752
|
-
|
|
40753
|
-
|
|
40754
|
-
|
|
40755
|
-
|
|
40756
|
-
|
|
40757
|
-
|
|
40758
|
-
|
|
40759
|
-
|
|
40760
|
-
|
|
40761
|
-
|
|
40762
|
-
|
|
40763
|
-
|
|
40764
|
-
|
|
40765
|
-
|
|
40766
|
-
|
|
40767
|
-
|
|
40768
|
-
|
|
40769
|
-
|
|
40770
|
-
|
|
40771
|
-
|
|
40772
|
-
|
|
40773
|
-
|
|
40774
|
-
|
|
40775
|
-
|
|
40776
|
-
|
|
40777
|
-
|
|
40778
|
-
|
|
40779
|
-
|
|
40780
|
-
|
|
40781
|
-
|
|
40782
|
-
|
|
40342
|
+
//#region src/orchestrator-types.ts
|
|
40343
|
+
var PHASE_MODE_VALUES = new Set([
|
|
40344
|
+
"disabled",
|
|
40345
|
+
"always-on",
|
|
40346
|
+
"on-motion"
|
|
40347
|
+
]);
|
|
40348
|
+
function isPipelinePhaseMode(v) {
|
|
40349
|
+
return PHASE_MODE_VALUES.has(v);
|
|
40350
|
+
}
|
|
40351
|
+
/**
|
|
40352
|
+
* Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
|
|
40353
|
+
* that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
|
|
40354
|
+
* `reconcileDispatch` is additive-only and never revisits these, so a slow
|
|
40355
|
+
* safety-net timer + event-driven debounce triggers recover them.
|
|
40356
|
+
*/
|
|
40357
|
+
var PENDING_RETRY_INTERVAL_MS = 6e4;
|
|
40358
|
+
/** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
|
|
40359
|
+
var PENDING_RETRY_DEBOUNCE_MS = 2e3;
|
|
40360
|
+
/**
|
|
40361
|
+
* Periodic auto-rebalance sweep. New attaches are already load-balanced at
|
|
40362
|
+
* dispatch time; this corrects DRIFT that accumulates over time (uneven
|
|
40363
|
+
* detach, a node returning online, a weight change) so the steady-state
|
|
40364
|
+
* distribution tracks the per-node weights. Only runs with ≥2 enabled detect
|
|
40365
|
+
* nodes, and migrates under hysteresis so a balanced cluster is a no-op.
|
|
40366
|
+
*/
|
|
40367
|
+
var AUTO_REBALANCE_INTERVAL_MS = 6e4;
|
|
40368
|
+
/**
|
|
40369
|
+
* Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
|
|
40370
|
+
* migrate a camera only when its target node is at least this much less loaded
|
|
40371
|
+
* than its current node. > 1 so equalizing a single-camera gap (which would
|
|
40372
|
+
* only reverse the imbalance) is skipped — prevents periodic churn.
|
|
40373
|
+
*/
|
|
40374
|
+
var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
|
|
40375
|
+
var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
|
|
40376
|
+
/**
|
|
40377
|
+
* Device-details keys routed through the orchestrator's pipeline
|
|
40378
|
+
* settings writer instead of the device orchestration store. The
|
|
40379
|
+
* `cameraPipeline` key carries the full `CameraPipelineConfig`
|
|
40380
|
+
* emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
|
|
40381
|
+
*/
|
|
40382
|
+
var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
|
|
40383
|
+
var DEFAULT_FAILOVER_POLICY = {
|
|
40384
|
+
onDisconnect: "migrate",
|
|
40385
|
+
pinnedOnDisconnect: "leave-pinned",
|
|
40386
|
+
onReconnect: "restore"
|
|
40387
|
+
};
|
|
40388
|
+
/**
|
|
40389
|
+
* Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
|
|
40390
|
+
*
|
|
40391
|
+
* The orchestrator's cap surface is the contract for all runtime traffic
|
|
40392
|
+
* (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
|
|
40393
|
+
* catalog is reserved for read-only diagnostics that are intentionally
|
|
40394
|
+
* outside the cap — they expose internal state (balancer caches, enabledNodes
|
|
40395
|
+
* set, active detection count) that is useful for admin tooling but does not
|
|
40396
|
+
* belong on the capability contract.
|
|
40397
|
+
*/
|
|
40398
|
+
var OrchestratorDiagnosticsSchema = object({
|
|
40399
|
+
localNodeId: string(),
|
|
40400
|
+
knownRunnerNodes: array(string()),
|
|
40401
|
+
cachedAgentLoadNodeIds: array(string()),
|
|
40402
|
+
enabledNodes: array(string()),
|
|
40403
|
+
enabledDecoderNodes: array(string()),
|
|
40404
|
+
enabledAudioNodes: array(string()),
|
|
40405
|
+
enabledIngestNodes: array(string()),
|
|
40406
|
+
clusterRoles: object({
|
|
40407
|
+
ingestNode: string(),
|
|
40408
|
+
audioNode: string(),
|
|
40409
|
+
motionNode: string()
|
|
40410
|
+
}),
|
|
40411
|
+
assignedDeviceCount: number().int().min(0),
|
|
40412
|
+
cameraConfigCount: number().int().min(0),
|
|
40413
|
+
activeDetectionCount: number().int().min(0)
|
|
40414
|
+
});
|
|
40415
|
+
/**
|
|
40416
|
+
* The node-stress long-term-statistics read surface.
|
|
40417
|
+
*
|
|
40418
|
+
* A custom action rather than a cap method, matching how the orchestrator
|
|
40419
|
+
* already serves `dumpState`: this is a hub-local read over a table the hub
|
|
40420
|
+
* owns, and it ships with one `camstack deploy` instead of a release train.
|
|
40421
|
+
* The MEAN is derived here and returned alongside the addable `sum`/`samples`
|
|
40422
|
+
* — a chart wants the first, a re-bucketing caller wants the second, and a
|
|
40423
|
+
* stored mean is a field that can disagree with both.
|
|
40424
|
+
*/
|
|
40425
|
+
var NodeStressStatsInputSchema = object({
|
|
40426
|
+
/** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
|
|
40427
|
+
series: string().optional(),
|
|
40428
|
+
/** A node id. Omit for every node. */
|
|
40429
|
+
subject: string().optional(),
|
|
40430
|
+
/** Inclusive bucket-start bounds, ms. */
|
|
40431
|
+
from: number().int().optional(),
|
|
40432
|
+
to: number().int().optional(),
|
|
40433
|
+
limit: number().int().positive().max(5e3).optional()
|
|
40434
|
+
});
|
|
40435
|
+
var NodeStressStatsRowSchema = object({
|
|
40436
|
+
subject: string(),
|
|
40437
|
+
series: string(),
|
|
40438
|
+
scope: string(),
|
|
40439
|
+
bucketStart: number(),
|
|
40440
|
+
samples: number(),
|
|
40441
|
+
sum: number(),
|
|
40442
|
+
mean: number(),
|
|
40443
|
+
min: number(),
|
|
40444
|
+
max: number()
|
|
40445
|
+
});
|
|
40446
|
+
var NodeStressStatsOutputSchema = object({
|
|
40447
|
+
rows: array(NodeStressStatsRowSchema).readonly(),
|
|
40448
|
+
/** Buckets still accumulating — "is it running" answerable at once, rather
|
|
40449
|
+
* than after five minutes of indistinguishable silence. */
|
|
40450
|
+
open: array(NodeStressStatsRowSchema).readonly(),
|
|
40451
|
+
/** The durable failover history the anti-flap guards read, newest first.
|
|
40452
|
+
* Exposed for the same reason the heartbeat exists: "nothing moved" has to
|
|
40453
|
+
* be distinguishable from "nothing is watching". */
|
|
40454
|
+
moves: array(object({
|
|
40455
|
+
deviceId: number(),
|
|
40456
|
+
fromNodeId: string(),
|
|
40457
|
+
at: number()
|
|
40458
|
+
})).readonly()
|
|
40459
|
+
});
|
|
40460
|
+
var pipelineOrchestratorActions = defineCustomActions({
|
|
40461
|
+
dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
|
|
40462
|
+
nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
|
|
40463
|
+
});
|
|
40464
|
+
/**
|
|
40465
|
+
* Sentinel returned by `buildDetectionConfig` when the profile-slot READ
|
|
40466
|
+
* itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
|
|
40467
|
+
* while the stream-broker is (re)starting) — as opposed to `null`, which
|
|
40468
|
+
* means "genuinely no assigned slot / not configured". Callers MUST treat
|
|
40469
|
+
* this differently from `null`: never stop active detection on a transient
|
|
40470
|
+
* read failure (the slots almost certainly still exist), and schedule a retry.
|
|
40471
|
+
*/
|
|
40472
|
+
var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
|
|
40473
|
+
//#endregion
|
|
40474
|
+
//#region src/audio-window-accumulator.ts
|
|
40475
|
+
var AudioWindowAccumulator = class {
|
|
40476
|
+
deviceId;
|
|
40477
|
+
pcmParts = [];
|
|
40478
|
+
accumulatedBytes = 0;
|
|
40479
|
+
accumulatedMs = 0;
|
|
40480
|
+
windowSampleRate = 0;
|
|
40481
|
+
windowChannels = 0;
|
|
40482
|
+
windowTimestamp = 0;
|
|
40483
|
+
windowOpen = false;
|
|
40484
|
+
constructor(deviceId) {
|
|
40485
|
+
this.deviceId = deviceId;
|
|
40486
|
+
}
|
|
40487
|
+
/**
|
|
40488
|
+
* Append one decoded PCM chunk to the open window. Returns the flushed
|
|
40489
|
+
* `AudioChunkInput` once the accumulated duration reaches
|
|
40490
|
+
* `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
|
|
40491
|
+
* (accumulate-only, no flush yet).
|
|
40492
|
+
*/
|
|
40493
|
+
push(chunk) {
|
|
40494
|
+
const byteLength = chunk.data.byteLength;
|
|
40495
|
+
const bytes = new Uint8Array(byteLength);
|
|
40496
|
+
bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
|
|
40497
|
+
if (!this.windowOpen) {
|
|
40498
|
+
this.windowSampleRate = chunk.sampleRate;
|
|
40499
|
+
this.windowChannels = chunk.channels;
|
|
40500
|
+
this.windowTimestamp = chunk.timestamp;
|
|
40501
|
+
this.windowOpen = true;
|
|
40502
|
+
}
|
|
40503
|
+
this.pcmParts.push(bytes);
|
|
40504
|
+
this.accumulatedBytes += byteLength;
|
|
40505
|
+
const channels = chunk.channels > 0 ? chunk.channels : 1;
|
|
40506
|
+
const framesPerChannel = byteLength / 4 / channels;
|
|
40507
|
+
this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
|
|
40508
|
+
if (this.accumulatedMs < 1e3) return null;
|
|
40509
|
+
const windowData = new Uint8Array(this.accumulatedBytes);
|
|
40510
|
+
let offset = 0;
|
|
40511
|
+
for (const part of this.pcmParts) {
|
|
40512
|
+
windowData.set(part, offset);
|
|
40513
|
+
offset += part.byteLength;
|
|
40514
|
+
}
|
|
40783
40515
|
const flushSampleRate = this.windowSampleRate;
|
|
40784
40516
|
const flushChannels = this.windowChannels;
|
|
40785
40517
|
const flushTimestamp = this.windowTimestamp;
|
|
@@ -41242,234 +40974,847 @@ var AudioSubscriptionController = class {
|
|
|
41242
40974
|
meta: { error: errMsg(err) }
|
|
41243
40975
|
});
|
|
41244
40976
|
});
|
|
41245
|
-
}, windowMs);
|
|
41246
|
-
this.motionAudioWindowTimers.set(deviceId, timer);
|
|
41247
|
-
}
|
|
41248
|
-
/** True while a motion-driven audio window is open for this device. */
|
|
41249
|
-
isMotionAudioWindowOpen(deviceId) {
|
|
41250
|
-
return this.motionAudioWindowTimers.has(deviceId);
|
|
41251
|
-
}
|
|
41252
|
-
/**
|
|
41253
|
-
* Close an on-motion audio window and drop its subscription. Shared by both
|
|
41254
|
-
* closers (quiet window elapsed / falling edge cooldown) so they can never
|
|
41255
|
-
* disagree about what "closed" means. `reason` is logged so a camera that
|
|
41256
|
-
* loses audio can always be told WHY from the per-device log view.
|
|
41257
|
-
*/
|
|
41258
|
-
async closeMotionAudioWindow(deviceId, reason) {
|
|
41259
|
-
const pendingWindow = this.motionAudioWindowTimers.get(deviceId);
|
|
41260
|
-
if (pendingWindow) {
|
|
41261
|
-
clearTimeout(pendingWindow);
|
|
41262
|
-
this.motionAudioWindowTimers.delete(deviceId);
|
|
40977
|
+
}, windowMs);
|
|
40978
|
+
this.motionAudioWindowTimers.set(deviceId, timer);
|
|
40979
|
+
}
|
|
40980
|
+
/** True while a motion-driven audio window is open for this device. */
|
|
40981
|
+
isMotionAudioWindowOpen(deviceId) {
|
|
40982
|
+
return this.motionAudioWindowTimers.has(deviceId);
|
|
40983
|
+
}
|
|
40984
|
+
/**
|
|
40985
|
+
* Close an on-motion audio window and drop its subscription. Shared by both
|
|
40986
|
+
* closers (quiet window elapsed / falling edge cooldown) so they can never
|
|
40987
|
+
* disagree about what "closed" means. `reason` is logged so a camera that
|
|
40988
|
+
* loses audio can always be told WHY from the per-device log view.
|
|
40989
|
+
*/
|
|
40990
|
+
async closeMotionAudioWindow(deviceId, reason) {
|
|
40991
|
+
const pendingWindow = this.motionAudioWindowTimers.get(deviceId);
|
|
40992
|
+
if (pendingWindow) {
|
|
40993
|
+
clearTimeout(pendingWindow);
|
|
40994
|
+
this.motionAudioWindowTimers.delete(deviceId);
|
|
40995
|
+
}
|
|
40996
|
+
await this.withAudioSubLock(deviceId, async () => {
|
|
40997
|
+
const unsub = this.audioSubscriptions.get(deviceId);
|
|
40998
|
+
if (!unsub) return;
|
|
40999
|
+
try {
|
|
41000
|
+
unsub();
|
|
41001
|
+
} catch {}
|
|
41002
|
+
this.audioSubscriptions.delete(deviceId);
|
|
41003
|
+
this.deps.logger.info("lazy audio: window closed", {
|
|
41004
|
+
tags: { deviceId },
|
|
41005
|
+
meta: { reason }
|
|
41006
|
+
});
|
|
41007
|
+
});
|
|
41008
|
+
}
|
|
41009
|
+
/**
|
|
41010
|
+
* Audio teardown for one device — the audio half of `stopDetection`.
|
|
41011
|
+
* Tears down through the per-device lock so it can't race a concurrent
|
|
41012
|
+
* subscribe (which would re-store a handle this teardown never sees).
|
|
41013
|
+
*/
|
|
41014
|
+
async stopForDevice(deviceId) {
|
|
41015
|
+
await this.withAudioSubLock(deviceId, async () => {
|
|
41016
|
+
const unsub = this.audioSubscriptions.get(deviceId);
|
|
41017
|
+
if (unsub) {
|
|
41018
|
+
try {
|
|
41019
|
+
unsub();
|
|
41020
|
+
} catch {}
|
|
41021
|
+
this.audioSubscriptions.delete(deviceId);
|
|
41022
|
+
}
|
|
41023
|
+
});
|
|
41024
|
+
const lazyTimer = this.lazyAudioTeardownTimers.get(deviceId);
|
|
41025
|
+
if (lazyTimer) {
|
|
41026
|
+
clearTimeout(lazyTimer);
|
|
41027
|
+
this.lazyAudioTeardownTimers.delete(deviceId);
|
|
41028
|
+
}
|
|
41029
|
+
const windowTimer = this.motionAudioWindowTimers.get(deviceId);
|
|
41030
|
+
if (windowTimer) {
|
|
41031
|
+
clearTimeout(windowTimer);
|
|
41032
|
+
this.motionAudioWindowTimers.delete(deviceId);
|
|
41033
|
+
}
|
|
41034
|
+
this.audioAssignments.delete(deviceId);
|
|
41035
|
+
}
|
|
41036
|
+
/**
|
|
41037
|
+
* Centralized write into `audioSubscriptions`. If shutdown has begun, the
|
|
41038
|
+
* map has already been (or is about to be) cleared lock-free in
|
|
41039
|
+
* `shutdown()`; storing here would leak a zombie entry whose `unsub` is
|
|
41040
|
+
* never called. So when shutting down we immediately invoke `unsub`
|
|
41041
|
+
* (best-effort, error-swallowed) and DO NOT store. `protected` so
|
|
41042
|
+
* `audio-sub-lock.spec.ts`'s test subclass can assert the shutdown-guard
|
|
41043
|
+
* behavior without casts.
|
|
41044
|
+
*/
|
|
41045
|
+
storeAudioSub(deviceId, unsub) {
|
|
41046
|
+
if (this.audioShuttingDown) {
|
|
41047
|
+
try {
|
|
41048
|
+
unsub();
|
|
41049
|
+
} catch {}
|
|
41050
|
+
return;
|
|
41051
|
+
}
|
|
41052
|
+
this.audioSubscriptions.set(deviceId, unsub);
|
|
41053
|
+
}
|
|
41054
|
+
/**
|
|
41055
|
+
* Serialize an audio-subscription critical section per device. `fn` is
|
|
41056
|
+
* chained onto the device's current lock tail, so concurrent calls for the
|
|
41057
|
+
* SAME deviceId run sequentially (FIFO); different deviceIds never block
|
|
41058
|
+
* each other. Thin delegate onto the `audioSubLocks` `KeyedAsyncLock`
|
|
41059
|
+
* instance. `protected` so `audio-sub-lock.spec.ts`'s test subclass can
|
|
41060
|
+
* drive the lock without casts.
|
|
41061
|
+
*/
|
|
41062
|
+
withAudioSubLock(deviceId, fn) {
|
|
41063
|
+
return this.audioSubLocks.run(deviceId, fn);
|
|
41064
|
+
}
|
|
41065
|
+
/**
|
|
41066
|
+
* Subscribe to decoded audio chunks for a camera and feed them into the
|
|
41067
|
+
* audio-analyzer. Reads the analyzer's settings via its own
|
|
41068
|
+
* `resolveDeviceSettings(deviceId)` method so the orchestrator does not
|
|
41069
|
+
* touch the audio-analyzer schema field names directly.
|
|
41070
|
+
*/
|
|
41071
|
+
async subscribeAudioStream(deviceId, config) {
|
|
41072
|
+
const api = this.deps.api();
|
|
41073
|
+
if (!api) {
|
|
41074
|
+
this.deps.logger.warn("this.ctx.api not available — cannot subscribe audio", { tags: { deviceId } });
|
|
41075
|
+
return null;
|
|
41076
|
+
}
|
|
41077
|
+
if (!await this.deps.isAudioAnalysisActive(deviceId)) return null;
|
|
41078
|
+
if (config.audioMode === "disabled") {
|
|
41079
|
+
this.deps.logger.debug("audio subscribe skipped: audioMode=disabled", { tags: { deviceId } });
|
|
41080
|
+
return null;
|
|
41081
|
+
}
|
|
41082
|
+
if (config.audioMode === "on-motion" && !this.isMotionAudioWindowOpen(deviceId)) {
|
|
41083
|
+
this.deps.logger.info("audio subscribe deferred: audioMode=on-motion, no window open", { tags: { deviceId } });
|
|
41084
|
+
return null;
|
|
41085
|
+
}
|
|
41086
|
+
const audioStream = config.audioStreamId ?? config.motionStreamId;
|
|
41087
|
+
const audioBrokerId = makeSourceBrokerId(deviceId, audioStream);
|
|
41088
|
+
if ((await this.deps.probeAudioTrack(deviceId, audioStream)).kind === "absent") {
|
|
41089
|
+
this.deps.logger.warn("audio subscription REFUSED — this stream carries no audio track", {
|
|
41090
|
+
tags: { deviceId },
|
|
41091
|
+
meta: {
|
|
41092
|
+
camStreamId: audioStream,
|
|
41093
|
+
brokerId: audioBrokerId,
|
|
41094
|
+
selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
|
|
41095
|
+
hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
|
|
41096
|
+
}
|
|
41097
|
+
});
|
|
41098
|
+
return null;
|
|
41099
|
+
}
|
|
41100
|
+
const settings = await api.audioAnalysis.resolveDeviceSettings.query({ deviceId });
|
|
41101
|
+
if (!settings) {
|
|
41102
|
+
this.deps.logger.warn("audio-analysis returned no settings — audio subscription skipped", { tags: { deviceId } });
|
|
41103
|
+
return null;
|
|
41104
|
+
}
|
|
41105
|
+
const audioNodeId = await this.dispatch(deviceId);
|
|
41106
|
+
const isRemoteAudio = audioNodeId !== this.deps.localNodeId();
|
|
41107
|
+
this.deps.logger.info("audio subscription: resolved audio node", {
|
|
41108
|
+
tags: { deviceId },
|
|
41109
|
+
meta: {
|
|
41110
|
+
audioNodeId,
|
|
41111
|
+
isRemote: isRemoteAudio
|
|
41112
|
+
}
|
|
41113
|
+
});
|
|
41114
|
+
const accumulator = new AudioWindowAccumulator(deviceId);
|
|
41115
|
+
const teardown = startAudioChunkPoller({
|
|
41116
|
+
api,
|
|
41117
|
+
brokerId: audioBrokerId,
|
|
41118
|
+
tag: "audio-analyzer",
|
|
41119
|
+
ownerNodeId: this.deps.ingestNode(),
|
|
41120
|
+
logger: this.deps.logger.withTags({ deviceId }),
|
|
41121
|
+
onChunk: async (chunk) => {
|
|
41122
|
+
this.deps.watchdogNote(deviceId, "audio");
|
|
41123
|
+
try {
|
|
41124
|
+
const audioChunkInput = accumulator.push(chunk);
|
|
41125
|
+
if (!audioChunkInput) return;
|
|
41126
|
+
const result = await api.audioAnalyzer.analyseChunk.mutate({
|
|
41127
|
+
chunk: audioChunkInput,
|
|
41128
|
+
settings,
|
|
41129
|
+
...isRemoteAudio ? { nodeId: audioNodeId } : {}
|
|
41130
|
+
});
|
|
41131
|
+
if (!result) return;
|
|
41132
|
+
const frame = buildAudioResultFrame(deviceId, result);
|
|
41133
|
+
this.deps.eventBus.emit({
|
|
41134
|
+
id: `audio-inference-${deviceId}-${Date.now()}`,
|
|
41135
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
41136
|
+
source: {
|
|
41137
|
+
type: "device",
|
|
41138
|
+
id: deviceId,
|
|
41139
|
+
nodeId: "hub",
|
|
41140
|
+
addonId: "pipeline-orchestrator",
|
|
41141
|
+
deviceId
|
|
41142
|
+
},
|
|
41143
|
+
category: EventCategory.PipelineAudioInferenceResult,
|
|
41144
|
+
data: {
|
|
41145
|
+
deviceId,
|
|
41146
|
+
frame,
|
|
41147
|
+
nodeId: "hub"
|
|
41148
|
+
}
|
|
41149
|
+
});
|
|
41150
|
+
} catch (err) {
|
|
41151
|
+
const msg = errMsg(err);
|
|
41152
|
+
this.deps.logger.error("Audio analysis failed", {
|
|
41153
|
+
tags: { deviceId },
|
|
41154
|
+
meta: { error: msg }
|
|
41155
|
+
});
|
|
41156
|
+
}
|
|
41157
|
+
}
|
|
41158
|
+
});
|
|
41159
|
+
this.deps.logger.info("Audio stream subscribed", { tags: { deviceId } });
|
|
41160
|
+
return () => {
|
|
41161
|
+
teardown();
|
|
41162
|
+
accumulator.reset();
|
|
41163
|
+
};
|
|
41164
|
+
}
|
|
41165
|
+
/**
|
|
41166
|
+
* Set true at the very start of `onShutdown`, before the audio teardown /
|
|
41167
|
+
* map clears below. Once set, `withAudioSubLock` turns queued/new critical
|
|
41168
|
+
* sections into no-ops and `storeAudioSub` refuses to store, so no
|
|
41169
|
+
* critical section that was in-flight (or queued) when shutdown began can
|
|
41170
|
+
* resurrect a zombie subscription into the cleared `audioSubscriptions`
|
|
41171
|
+
* map. MUST be called before anything else in `onShutdown` that could
|
|
41172
|
+
* race a queued audio critical section (mirrors the original
|
|
41173
|
+
* `this.audioShuttingDown = true` being the very first statement).
|
|
41174
|
+
*/
|
|
41175
|
+
beginShutdown() {
|
|
41176
|
+
this.audioShuttingDown = true;
|
|
41177
|
+
}
|
|
41178
|
+
/**
|
|
41179
|
+
* Full audio teardown — combines the former `onShutdown`'s two separate
|
|
41180
|
+
* audio blocks (lazy-teardown-timer clear, then — after several unrelated
|
|
41181
|
+
* session/reconcile/load-shed clears — subscription teardown + lock clear
|
|
41182
|
+
* + assignment-map clears) into one call. Safe to combine: both blocks
|
|
41183
|
+
* are synchronous with no interleaved `await`, and every original
|
|
41184
|
+
* statement between them (`sessionRegistry.clear()`,
|
|
41185
|
+
* `cameraFpsMap.clear()`, `remoteHealthAttempts.clear()`,
|
|
41186
|
+
* `loadShedState.clear()`, `loadShedResumeTimer` cleanup) touches state
|
|
41187
|
+
* fully disjoint from anything audio — so their relative order to each
|
|
41188
|
+
* other is unaffected, and the audio-internal order (timers →
|
|
41189
|
+
* subscriptions → lock → assignment maps) is reproduced exactly.
|
|
41190
|
+
*/
|
|
41191
|
+
shutdown() {
|
|
41192
|
+
for (const t of this.lazyAudioTeardownTimers.values()) clearTimeout(t);
|
|
41193
|
+
this.lazyAudioTeardownTimers.clear();
|
|
41194
|
+
for (const t of this.motionAudioWindowTimers.values()) clearTimeout(t);
|
|
41195
|
+
this.motionAudioWindowTimers.clear();
|
|
41196
|
+
for (const unsub of this.audioSubscriptions.values()) try {
|
|
41197
|
+
unsub();
|
|
41198
|
+
} catch {}
|
|
41199
|
+
this.audioSubscriptions.clear();
|
|
41200
|
+
this.audioSubLocks.clear();
|
|
41201
|
+
this.audioAssignments.clear();
|
|
41202
|
+
this.readyAudioNodes.clear();
|
|
41203
|
+
}
|
|
41204
|
+
};
|
|
41205
|
+
//#endregion
|
|
41206
|
+
//#region src/disk-reconcile-fleet.ts
|
|
41207
|
+
async function reconcileFleetFromDisk(deps) {
|
|
41208
|
+
const deviceIds = await deps.listDeviceIds();
|
|
41209
|
+
const failed = [];
|
|
41210
|
+
let cameras = 0;
|
|
41211
|
+
let mediaDropped = 0;
|
|
41212
|
+
let tracks = 0;
|
|
41213
|
+
let events = 0;
|
|
41214
|
+
let completed = 0;
|
|
41215
|
+
for (const deviceId of deviceIds) {
|
|
41216
|
+
try {
|
|
41217
|
+
await deps.rescanRecordings(deviceId);
|
|
41218
|
+
const counts = await deps.reconcileAnalytics(deviceId);
|
|
41219
|
+
cameras += 1;
|
|
41220
|
+
mediaDropped += counts.mediaDropped;
|
|
41221
|
+
tracks += counts.tracks;
|
|
41222
|
+
events += counts.events;
|
|
41223
|
+
} catch {
|
|
41224
|
+
failed.push(deviceId);
|
|
41225
|
+
}
|
|
41226
|
+
completed += 1;
|
|
41227
|
+
deps.onProgress?.({
|
|
41228
|
+
deviceId,
|
|
41229
|
+
total: deviceIds.length,
|
|
41230
|
+
completed,
|
|
41231
|
+
failed: [...failed],
|
|
41232
|
+
mediaDropped,
|
|
41233
|
+
tracks,
|
|
41234
|
+
events
|
|
41235
|
+
});
|
|
41236
|
+
}
|
|
41237
|
+
return {
|
|
41238
|
+
cameras,
|
|
41239
|
+
failed,
|
|
41240
|
+
mediaDropped,
|
|
41241
|
+
tracks,
|
|
41242
|
+
events
|
|
41243
|
+
};
|
|
41244
|
+
}
|
|
41245
|
+
//#endregion
|
|
41246
|
+
//#region src/disk-reconcile-job.ts
|
|
41247
|
+
/**
|
|
41248
|
+
* In-memory disk-wins fleet job. The tRPC mutation starts this and returns
|
|
41249
|
+
* immediately; the walk runs in the addon process so a 60s UDS timeout cannot
|
|
41250
|
+
* abort it. Status is polled via getReconcileFromDiskStatus.
|
|
41251
|
+
*/
|
|
41252
|
+
function idleDiskReconcileJob() {
|
|
41253
|
+
return {
|
|
41254
|
+
state: "idle",
|
|
41255
|
+
total: 0,
|
|
41256
|
+
completed: 0,
|
|
41257
|
+
currentDeviceId: null,
|
|
41258
|
+
failed: [],
|
|
41259
|
+
mediaDropped: 0,
|
|
41260
|
+
tracks: 0,
|
|
41261
|
+
events: 0,
|
|
41262
|
+
startedAtMs: null,
|
|
41263
|
+
finishedAtMs: null,
|
|
41264
|
+
error: null
|
|
41265
|
+
};
|
|
41266
|
+
}
|
|
41267
|
+
function isTimeoutError(err) {
|
|
41268
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41269
|
+
return /timed out/i.test(message);
|
|
41270
|
+
}
|
|
41271
|
+
async function withTimeoutRetry(run) {
|
|
41272
|
+
try {
|
|
41273
|
+
return await run();
|
|
41274
|
+
} catch (err) {
|
|
41275
|
+
if (!isTimeoutError(err)) throw err;
|
|
41276
|
+
return await run();
|
|
41277
|
+
}
|
|
41278
|
+
}
|
|
41279
|
+
function createDiskReconcileJobRunner(now = Date.now) {
|
|
41280
|
+
let job = idleDiskReconcileJob();
|
|
41281
|
+
let inFlight = null;
|
|
41282
|
+
const snapshot = () => job;
|
|
41283
|
+
const start = (deps) => {
|
|
41284
|
+
if (job.state === "running" && inFlight) return job;
|
|
41285
|
+
job = {
|
|
41286
|
+
...idleDiskReconcileJob(),
|
|
41287
|
+
state: "running",
|
|
41288
|
+
startedAtMs: now()
|
|
41289
|
+
};
|
|
41290
|
+
deps.log?.("pipeline disk reconcile started");
|
|
41291
|
+
inFlight = (async () => {
|
|
41292
|
+
try {
|
|
41293
|
+
const result = await reconcileFleetFromDisk({
|
|
41294
|
+
listDeviceIds: deps.listDeviceIds,
|
|
41295
|
+
rescanRecordings: (deviceId) => withTimeoutRetry(() => deps.rescanRecordings(deviceId)),
|
|
41296
|
+
reconcileAnalytics: (deviceId) => withTimeoutRetry(() => deps.reconcileAnalytics(deviceId)),
|
|
41297
|
+
onProgress: (update) => {
|
|
41298
|
+
job = {
|
|
41299
|
+
...job,
|
|
41300
|
+
total: update.total,
|
|
41301
|
+
completed: update.completed,
|
|
41302
|
+
currentDeviceId: update.deviceId,
|
|
41303
|
+
failed: update.failed,
|
|
41304
|
+
mediaDropped: update.mediaDropped,
|
|
41305
|
+
tracks: update.tracks,
|
|
41306
|
+
events: update.events
|
|
41307
|
+
};
|
|
41308
|
+
deps.onProgress?.(update);
|
|
41309
|
+
deps.log?.("pipeline disk reconcile camera", {
|
|
41310
|
+
deviceId: update.deviceId,
|
|
41311
|
+
completed: update.completed,
|
|
41312
|
+
total: update.total,
|
|
41313
|
+
failed: update.failed.length,
|
|
41314
|
+
mediaDropped: update.mediaDropped,
|
|
41315
|
+
tracks: update.tracks,
|
|
41316
|
+
events: update.events
|
|
41317
|
+
});
|
|
41318
|
+
}
|
|
41319
|
+
});
|
|
41320
|
+
job = {
|
|
41321
|
+
...job,
|
|
41322
|
+
state: "done",
|
|
41323
|
+
total: result.cameras + result.failed.length,
|
|
41324
|
+
completed: result.cameras + result.failed.length,
|
|
41325
|
+
currentDeviceId: null,
|
|
41326
|
+
failed: result.failed,
|
|
41327
|
+
mediaDropped: result.mediaDropped,
|
|
41328
|
+
tracks: result.tracks,
|
|
41329
|
+
events: result.events,
|
|
41330
|
+
finishedAtMs: now(),
|
|
41331
|
+
error: null
|
|
41332
|
+
};
|
|
41333
|
+
deps.log?.("pipeline disk reconcile", {
|
|
41334
|
+
cameras: result.cameras,
|
|
41335
|
+
failed: result.failed,
|
|
41336
|
+
mediaDropped: result.mediaDropped,
|
|
41337
|
+
tracks: result.tracks,
|
|
41338
|
+
events: result.events
|
|
41339
|
+
});
|
|
41340
|
+
} catch (err) {
|
|
41341
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
41342
|
+
job = {
|
|
41343
|
+
...job,
|
|
41344
|
+
state: "error",
|
|
41345
|
+
currentDeviceId: null,
|
|
41346
|
+
finishedAtMs: now(),
|
|
41347
|
+
error
|
|
41348
|
+
};
|
|
41349
|
+
deps.log?.("pipeline disk reconcile failed", { error });
|
|
41350
|
+
} finally {
|
|
41351
|
+
inFlight = null;
|
|
41352
|
+
}
|
|
41353
|
+
})();
|
|
41354
|
+
return job;
|
|
41355
|
+
};
|
|
41356
|
+
return {
|
|
41357
|
+
snapshot,
|
|
41358
|
+
start
|
|
41359
|
+
};
|
|
41360
|
+
}
|
|
41361
|
+
//#endregion
|
|
41362
|
+
//#region src/inference-device-model.ts
|
|
41363
|
+
/**
|
|
41364
|
+
* Per-device default object-detection model + deviceKey parsing for the
|
|
41365
|
+
* orchestrator's device-aware `getNodeInferenceDevices` view.
|
|
41366
|
+
*
|
|
41367
|
+
* This DUPLICATES the executor's per-device model resolution (P0-3:
|
|
41368
|
+
* `resolveDeviceEngine` + `MODEL_BY_CLASS` + the object-detection step's
|
|
41369
|
+
* `defaultModelIdByFormat` in `@camstack/addon-pipeline`). It is duplicated —
|
|
41370
|
+
* not imported — because cross-addon imports are forbidden (the orchestrator
|
|
41371
|
+
* and the detection-pipeline are separate addons; only tRPC crosses the
|
|
41372
|
+
* boundary). Keep this in sync with `default-detection-model.ts` /
|
|
41373
|
+
* `step-definitions.ts` if the executor's defaults change.
|
|
41374
|
+
*
|
|
41375
|
+
* The returned ids are honest catalog ids (verified present):
|
|
41376
|
+
* - `yolov9m-320-int8` — Intel NPU + iGPU (yolo26 does NOT compile on the NPU)
|
|
41377
|
+
* - `yolov9m-320` — Apple ANE (CoreML)
|
|
41378
|
+
* - `ssd-mobilenet-v2-coco-edgetpu` — Coral USB Edge TPU (tflite)
|
|
41379
|
+
* - `yolo26n` — CPU / CUDA (the object-detection step's universal
|
|
41380
|
+
* nano default)
|
|
41381
|
+
*
|
|
41382
|
+
* Do not promote the accelerated ids to 640. The evaluation in
|
|
41383
|
+
* `docs/benchmarks/pipeline-frame-model-eval.md` failed the 640 promotion
|
|
41384
|
+
* gates (0/3 miss recovered at the current threshold).
|
|
41385
|
+
*/
|
|
41386
|
+
/**
|
|
41387
|
+
* The always-on object-detection ROOT step id. A camera session's tracks all
|
|
41388
|
+
* originate from this detector, so a device whose engine format can't run it
|
|
41389
|
+
* cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
|
|
41390
|
+
* import is forbidden — this is the same duplication rationale as the model
|
|
41391
|
+
* defaults above).
|
|
41392
|
+
*/
|
|
41393
|
+
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
41394
|
+
/**
|
|
41395
|
+
* Build the camera-root capability predicate for a node from its live catalog:
|
|
41396
|
+
* `format → canHostCameraRoot`. A format can host a camera root iff the
|
|
41397
|
+
* catalog lists at least one object-detection model with a build for that
|
|
41398
|
+
* format — byte-for-byte the resolver's per-device skip-gate test for the root
|
|
41399
|
+
* step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
|
|
41400
|
+
* would ACTUALLY provision on it.
|
|
41401
|
+
*
|
|
41402
|
+
* Fails OPEN when the catalog has no object-detection slot at all (never
|
|
41403
|
+
* observed in production) so a malformed/empty catalog never strands every
|
|
41404
|
+
* device off the balancer. Pure + deterministic.
|
|
41405
|
+
*/
|
|
41406
|
+
function makeRootCapabilityGuard(catalog) {
|
|
41407
|
+
for (const slot of catalog.slots) {
|
|
41408
|
+
const objDet = slot.addons.find((a) => a.id === OBJECT_DETECTION_STEP_ID);
|
|
41409
|
+
if (objDet) return (format) => objDet.models.some((m) => Boolean(m.formats[format]));
|
|
41410
|
+
}
|
|
41411
|
+
return () => true;
|
|
41412
|
+
}
|
|
41413
|
+
/** Split a deviceKey (`<backend>:<device>`, or bare `cpu`) into its parts + format.
|
|
41414
|
+
* Format comes from the shared {@link deviceBackendToFormat} SSOT (`@camstack/types`)
|
|
41415
|
+
* — the previously-local `BACKEND_FORMAT` copy is gone (R3/node-F2). Used only for
|
|
41416
|
+
* STORED-ONLY keys (a configured device the live probe didn't return); a probed
|
|
41417
|
+
* device carries its own honest `format` from the descriptor. */
|
|
41418
|
+
function parseDeviceKey(deviceKey) {
|
|
41419
|
+
const colon = deviceKey.indexOf(":");
|
|
41420
|
+
const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
|
|
41421
|
+
return {
|
|
41422
|
+
backend,
|
|
41423
|
+
device: colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey,
|
|
41424
|
+
format: deviceBackendToFormat(backend)
|
|
41425
|
+
};
|
|
41426
|
+
}
|
|
41427
|
+
/**
|
|
41428
|
+
* The object-detection model the executor defaults to for a deviceKey. Mirrors
|
|
41429
|
+
* the executor's `MODEL_BY_CLASS` classification (`classifyAccelerator`) plus
|
|
41430
|
+
* the tflite `defaultModelIdByFormat` for Coral. Never throws; unknown backends
|
|
41431
|
+
* fall back to the universal nano default (`yolo26n`).
|
|
41432
|
+
*/
|
|
41433
|
+
function defaultModelIdForDevice(deviceKey) {
|
|
41434
|
+
const { backend, device } = parseDeviceKey(deviceKey);
|
|
41435
|
+
if (backend === "openvino") {
|
|
41436
|
+
if (device === "cpu") return "yolo26n";
|
|
41437
|
+
return "yolov9m-320-int8";
|
|
41438
|
+
}
|
|
41439
|
+
if (backend === "edgetpu") return "ssd-mobilenet-v2-coco-edgetpu";
|
|
41440
|
+
if (backend === "coreml") return "yolov9m-320";
|
|
41441
|
+
return "yolo26n";
|
|
41442
|
+
}
|
|
41443
|
+
/**
|
|
41444
|
+
* Step-tree device jump (phase 1): validate every `steps[step].jumpDeviceKey`
|
|
41445
|
+
* manual override in a to-be-saved `inferenceDevices` map. A jump target MUST be
|
|
41446
|
+
* an enabled∧available device on the SAME node and DIFFERENT from the owning
|
|
41447
|
+
* device. `enabledAvailableKeys` is the effective enabled∧available set (from
|
|
41448
|
+
* `mergeInferenceDevices(probe, submitted)`) so an absent/unplugged/disabled
|
|
41449
|
+
* target is rejected honestly (an operator can't route a step onto a dead pool).
|
|
41450
|
+
* Returns the FIRST human-readable error, or `null` when every override is
|
|
41451
|
+
* valid. Pure + deterministic.
|
|
41452
|
+
*/
|
|
41453
|
+
function validateJumpTargets(inferenceDevices, enabledAvailableKeys) {
|
|
41454
|
+
for (const [deviceKey, entry] of Object.entries(inferenceDevices)) for (const [stepId, step] of Object.entries(entry.steps ?? {})) {
|
|
41455
|
+
const target = step.jumpDeviceKey;
|
|
41456
|
+
if (target === void 0) continue;
|
|
41457
|
+
if (target === deviceKey) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey pointing at its own device`;
|
|
41458
|
+
if (!enabledAvailableKeys.has(target)) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey "${target}" that is not an enabled, available device on this node`;
|
|
41459
|
+
}
|
|
41460
|
+
return null;
|
|
41461
|
+
}
|
|
41462
|
+
/**
|
|
41463
|
+
* Merge a node's live-probed inference devices with its stored per-device map.
|
|
41464
|
+
*
|
|
41465
|
+
* The default is **AUTO = all discovered ACCELERATORS enabled** (spec C2,
|
|
41466
|
+
* opt-OUT) with TWO deliberate exceptions, both **opt-IN** (default disabled):
|
|
41467
|
+
*
|
|
41468
|
+
* - **CPU**: `enumerateInferenceDevices` always emits a universal `cpu`
|
|
41469
|
+
* floor on every platform; auto-enabling it would let the balancer
|
|
41470
|
+
* round-robin ~1/N of sessions onto the slow CPU pool alongside the
|
|
41471
|
+
* NPU/iGPU/ANE. CPU stays the always-available FALLBACK (a node with no
|
|
41472
|
+
* eligible accelerator leaves `deviceKey` unset → the runner's default
|
|
41473
|
+
* pool, which is CPU), not a balanced target — matching the spec's "no
|
|
41474
|
+
* device eligible → fall back to CPU".
|
|
41475
|
+
* - **Coral Edge TPU (`edgetpu`)**: the standing rule since the Coral
|
|
41476
|
+
* executor landed is that it surfaces as selectable but is NEVER
|
|
41477
|
+
* auto-picked — it runs a DIFFERENT, weaker model family (tflite SSD
|
|
41478
|
+
* MobileNet, not the YOLO the other accelerators run), so silently
|
|
41479
|
+
* enrolling a plugged-in Coral changes detection QUALITY, not just
|
|
41480
|
+
* placement. The opt-OUT default did exactly that on 2026-08-01: a hub
|
|
41481
|
+
* Coral nobody enabled entered the session rotation and camera 615 spent
|
|
41482
|
+
* hours at 2.4fps failing tflite model resolution. An operator who wants
|
|
41483
|
+
* the Coral balanced opts it in explicitly (`enabled: true`).
|
|
41484
|
+
*
|
|
41485
|
+
* So: an NPU/iGPU/ANE accelerator with NO stored entry is `enabled:true`; a
|
|
41486
|
+
* CPU or edgetpu device with no stored entry is `enabled:false`; an explicit
|
|
41487
|
+
* stored `enabled` always wins (an operator can opt CPU/Coral in, or an
|
|
41488
|
+
* accelerator out). A stored-only key (configured but the probe did not
|
|
41489
|
+
* return it — removed/unplugged HW) keeps its stored `enabled` and surfaces
|
|
41490
|
+
* as `available:false`, so the UI still shows it.
|
|
41491
|
+
*
|
|
41492
|
+
* Pure + deterministic (sorted by key) — the single merge authority shared by
|
|
41493
|
+
* the `getNodeInferenceDevices` view and the dispatcher's eligible-device pick.
|
|
41494
|
+
*/
|
|
41495
|
+
function mergeInferenceDevices(probed, stored) {
|
|
41496
|
+
const probedByKey = new Map(probed.map((d) => [d.key, d]));
|
|
41497
|
+
const keys = new Set([...probedByKey.keys(), ...Object.keys(stored)]);
|
|
41498
|
+
const out = [];
|
|
41499
|
+
for (const key of Array.from(keys).toSorted()) {
|
|
41500
|
+
const descriptor = probedByKey.get(key);
|
|
41501
|
+
const opt = stored[key];
|
|
41502
|
+
const parsed = descriptor ?? parseDeviceKey(key);
|
|
41503
|
+
const weight = opt?.weight !== void 0 && opt.weight > 0 ? opt.weight : 1;
|
|
41504
|
+
const autoDefault = parsed.backend !== "cpu" && parsed.backend !== "edgetpu";
|
|
41505
|
+
out.push({
|
|
41506
|
+
key,
|
|
41507
|
+
backend: parsed.backend,
|
|
41508
|
+
device: parsed.device,
|
|
41509
|
+
format: parsed.format,
|
|
41510
|
+
available: descriptor?.available ?? false,
|
|
41511
|
+
enabled: opt?.enabled ?? autoDefault,
|
|
41512
|
+
weight,
|
|
41513
|
+
maxSessions: opt?.maxSessions ?? null,
|
|
41514
|
+
defaultModelId: defaultModelIdForDevice(key),
|
|
41515
|
+
...opt?.steps && Object.keys(opt.steps).length > 0 ? { steps: { ...opt.steps } } : {}
|
|
41516
|
+
});
|
|
41517
|
+
}
|
|
41518
|
+
return out;
|
|
41519
|
+
}
|
|
41520
|
+
/**
|
|
41521
|
+
* The per-device concurrent-session caps for a node as `deviceKey → maxSessions`
|
|
41522
|
+
* (only devices that carry an explicit cap; absent = unlimited). Fed to the
|
|
41523
|
+
* device balancer's `nodeCaps` so a device at its cap is skipped (audit F3).
|
|
41524
|
+
*/
|
|
41525
|
+
function inferenceDeviceCaps(stored) {
|
|
41526
|
+
const out = {};
|
|
41527
|
+
for (const [key, entry] of Object.entries(stored)) if (entry.maxSessions !== void 0 && entry.maxSessions > 0) out[key] = entry.maxSessions;
|
|
41528
|
+
return out;
|
|
41529
|
+
}
|
|
41530
|
+
/** Is this device the CPU fallback rather than a real accelerator? */
|
|
41531
|
+
function isCpuFallback(view) {
|
|
41532
|
+
return view.backend === "cpu";
|
|
41533
|
+
}
|
|
41534
|
+
function resolveInferenceDeviceEligibility(probed, stored, canRunRoot, isPoolUsable) {
|
|
41535
|
+
const eligible = {};
|
|
41536
|
+
const excluded = [];
|
|
41537
|
+
const merged = mergeInferenceDevices(probed, stored);
|
|
41538
|
+
const acceleratorServes = merged.some((d) => !isCpuFallback(d) && d.enabled && d.available && (!canRunRoot || canRunRoot(d.format)));
|
|
41539
|
+
for (const d of merged) {
|
|
41540
|
+
if (!d.enabled) {
|
|
41541
|
+
excluded.push({
|
|
41542
|
+
key: d.key,
|
|
41543
|
+
reason: "disabled",
|
|
41544
|
+
format: d.format
|
|
41545
|
+
});
|
|
41546
|
+
continue;
|
|
41547
|
+
}
|
|
41548
|
+
if (!d.available) {
|
|
41549
|
+
excluded.push({
|
|
41550
|
+
key: d.key,
|
|
41551
|
+
reason: "unavailable",
|
|
41552
|
+
format: d.format
|
|
41553
|
+
});
|
|
41554
|
+
continue;
|
|
41263
41555
|
}
|
|
41264
|
-
|
|
41265
|
-
|
|
41266
|
-
|
|
41267
|
-
|
|
41268
|
-
|
|
41269
|
-
} catch {}
|
|
41270
|
-
this.audioSubscriptions.delete(deviceId);
|
|
41271
|
-
this.deps.logger.info("lazy audio: window closed", {
|
|
41272
|
-
tags: { deviceId },
|
|
41273
|
-
meta: { reason }
|
|
41556
|
+
if (isPoolUsable && !isPoolUsable(d.key)) {
|
|
41557
|
+
excluded.push({
|
|
41558
|
+
key: d.key,
|
|
41559
|
+
reason: "unavailable",
|
|
41560
|
+
format: d.format
|
|
41274
41561
|
});
|
|
41275
|
-
|
|
41276
|
-
}
|
|
41277
|
-
/**
|
|
41278
|
-
* Audio teardown for one device — the audio half of `stopDetection`.
|
|
41279
|
-
* Tears down through the per-device lock so it can't race a concurrent
|
|
41280
|
-
* subscribe (which would re-store a handle this teardown never sees).
|
|
41281
|
-
*/
|
|
41282
|
-
async stopForDevice(deviceId) {
|
|
41283
|
-
await this.withAudioSubLock(deviceId, async () => {
|
|
41284
|
-
const unsub = this.audioSubscriptions.get(deviceId);
|
|
41285
|
-
if (unsub) {
|
|
41286
|
-
try {
|
|
41287
|
-
unsub();
|
|
41288
|
-
} catch {}
|
|
41289
|
-
this.audioSubscriptions.delete(deviceId);
|
|
41290
|
-
}
|
|
41291
|
-
});
|
|
41292
|
-
const lazyTimer = this.lazyAudioTeardownTimers.get(deviceId);
|
|
41293
|
-
if (lazyTimer) {
|
|
41294
|
-
clearTimeout(lazyTimer);
|
|
41295
|
-
this.lazyAudioTeardownTimers.delete(deviceId);
|
|
41562
|
+
continue;
|
|
41296
41563
|
}
|
|
41297
|
-
|
|
41298
|
-
|
|
41299
|
-
|
|
41300
|
-
|
|
41564
|
+
if (canRunRoot && !canRunRoot(d.format)) {
|
|
41565
|
+
excluded.push({
|
|
41566
|
+
key: d.key,
|
|
41567
|
+
reason: "cannot-host-camera-root",
|
|
41568
|
+
format: d.format
|
|
41569
|
+
});
|
|
41570
|
+
continue;
|
|
41301
41571
|
}
|
|
41302
|
-
|
|
41303
|
-
|
|
41304
|
-
|
|
41305
|
-
|
|
41306
|
-
|
|
41307
|
-
|
|
41308
|
-
|
|
41309
|
-
* (best-effort, error-swallowed) and DO NOT store. `protected` so
|
|
41310
|
-
* `audio-sub-lock.spec.ts`'s test subclass can assert the shutdown-guard
|
|
41311
|
-
* behavior without casts.
|
|
41312
|
-
*/
|
|
41313
|
-
storeAudioSub(deviceId, unsub) {
|
|
41314
|
-
if (this.audioShuttingDown) {
|
|
41315
|
-
try {
|
|
41316
|
-
unsub();
|
|
41317
|
-
} catch {}
|
|
41318
|
-
return;
|
|
41572
|
+
if (isCpuFallback(d) && acceleratorServes) {
|
|
41573
|
+
excluded.push({
|
|
41574
|
+
key: d.key,
|
|
41575
|
+
reason: "accelerator-preferred",
|
|
41576
|
+
format: d.format
|
|
41577
|
+
});
|
|
41578
|
+
continue;
|
|
41319
41579
|
}
|
|
41320
|
-
|
|
41321
|
-
}
|
|
41322
|
-
/**
|
|
41323
|
-
* Serialize an audio-subscription critical section per device. `fn` is
|
|
41324
|
-
* chained onto the device's current lock tail, so concurrent calls for the
|
|
41325
|
-
* SAME deviceId run sequentially (FIFO); different deviceIds never block
|
|
41326
|
-
* each other. Thin delegate onto the `audioSubLocks` `KeyedAsyncLock`
|
|
41327
|
-
* instance. `protected` so `audio-sub-lock.spec.ts`'s test subclass can
|
|
41328
|
-
* drive the lock without casts.
|
|
41329
|
-
*/
|
|
41330
|
-
withAudioSubLock(deviceId, fn) {
|
|
41331
|
-
return this.audioSubLocks.run(deviceId, fn);
|
|
41580
|
+
eligible[d.key] = d.weight;
|
|
41332
41581
|
}
|
|
41582
|
+
return {
|
|
41583
|
+
eligible,
|
|
41584
|
+
excluded
|
|
41585
|
+
};
|
|
41586
|
+
}
|
|
41587
|
+
/**
|
|
41588
|
+
* Join the merged device rows with the eligibility verdict so the UI can NAME
|
|
41589
|
+
* why an accelerator is not in play instead of leaving the operator to deduce
|
|
41590
|
+
* it from `enabled`/`available`.
|
|
41591
|
+
*
|
|
41592
|
+
* Deduction is not possible for two of the four reasons — `accelerator-preferred`
|
|
41593
|
+
* is a node-WIDE rule (a CPU row reads `enabled:true, available:true` and still
|
|
41594
|
+
* never gets a session, D215) and `cannot-host-camera-root` needs the node's
|
|
41595
|
+
* model catalog. Both live in {@link resolveInferenceDeviceEligibility}, so this
|
|
41596
|
+
* function only transports its answer; it never re-derives one.
|
|
41597
|
+
*
|
|
41598
|
+
* Pure; preserves `merged`'s order (sorted by key) and every other field.
|
|
41599
|
+
*/
|
|
41600
|
+
function annotateInferenceDeviceExclusions(merged, eligibility) {
|
|
41601
|
+
const reasonByKey = new Map(eligibility.excluded.map((e) => [e.key, e.reason]));
|
|
41602
|
+
return merged.map((view) => ({
|
|
41603
|
+
...view,
|
|
41604
|
+
exclusion: reasonByKey.get(view.key) ?? null
|
|
41605
|
+
}));
|
|
41606
|
+
}
|
|
41607
|
+
function resolveNodeInferenceUsability(eligibility) {
|
|
41608
|
+
const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
|
|
41609
|
+
const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
|
|
41610
|
+
return {
|
|
41611
|
+
usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
|
|
41612
|
+
unavailableKeys,
|
|
41613
|
+
eligibleKeys
|
|
41614
|
+
};
|
|
41615
|
+
}
|
|
41616
|
+
/**
|
|
41617
|
+
* Step-tree device jump (phase 1): the attach-payload roster of a node's
|
|
41618
|
+
* enabled∧available inference devices with the balancer knobs (`weight`,
|
|
41619
|
+
* `maxSessions`) the runner uses to AUTO-jump an enrichment step off a device
|
|
41620
|
+
* whose format can't run it. Built from the SAME `eligible` (deviceKey→weight)
|
|
41621
|
+
* and `caps` (deviceKey→maxSessions) the dispatcher already computes, so the
|
|
41622
|
+
* roster the runner sees exactly matches the balancer's candidate set. Sorted
|
|
41623
|
+
* by key for determinism. Populated onto `RunnerCameraConfig.inferenceDevices`
|
|
41624
|
+
* ONLY when a `deviceKey` is elected and there are ≥2 entries.
|
|
41625
|
+
*/
|
|
41626
|
+
function buildInferenceDeviceRoster(eligible, caps) {
|
|
41627
|
+
return Object.entries(eligible).map(([deviceKey, weight]) => ({
|
|
41628
|
+
deviceKey,
|
|
41629
|
+
weight: weight > 0 ? weight : 1,
|
|
41630
|
+
maxSessions: caps[deviceKey] ?? null
|
|
41631
|
+
})).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
|
|
41632
|
+
}
|
|
41633
|
+
//#endregion
|
|
41634
|
+
//#region src/node-inference-usability-mirror.ts
|
|
41635
|
+
var NodeInferenceUsabilityMirror = class {
|
|
41636
|
+
state = /* @__PURE__ */ new Map();
|
|
41333
41637
|
/**
|
|
41334
|
-
*
|
|
41335
|
-
*
|
|
41336
|
-
* `resolveDeviceSettings(deviceId)` method so the orchestrator does not
|
|
41337
|
-
* touch the audio-analyzer schema field names directly.
|
|
41638
|
+
* Fold one observation in and report whether the caller should act.
|
|
41639
|
+
* Never throws.
|
|
41338
41640
|
*/
|
|
41339
|
-
|
|
41340
|
-
const
|
|
41341
|
-
if (
|
|
41342
|
-
this.
|
|
41343
|
-
|
|
41344
|
-
|
|
41345
|
-
|
|
41346
|
-
|
|
41347
|
-
this.deps.logger.debug("audio subscribe skipped: audioMode=disabled", { tags: { deviceId } });
|
|
41348
|
-
return null;
|
|
41641
|
+
observe(nodeId, usable) {
|
|
41642
|
+
const prev = this.state.get(nodeId);
|
|
41643
|
+
if (usable) {
|
|
41644
|
+
this.state.set(nodeId, {
|
|
41645
|
+
usable: true,
|
|
41646
|
+
armed: false
|
|
41647
|
+
});
|
|
41648
|
+
return prev !== void 0 && !prev.usable ? "recovered" : null;
|
|
41349
41649
|
}
|
|
41350
|
-
if (
|
|
41351
|
-
this.
|
|
41650
|
+
if (prev === void 0) {
|
|
41651
|
+
this.state.set(nodeId, {
|
|
41652
|
+
usable: true,
|
|
41653
|
+
armed: true
|
|
41654
|
+
});
|
|
41352
41655
|
return null;
|
|
41353
41656
|
}
|
|
41354
|
-
|
|
41355
|
-
|
|
41356
|
-
|
|
41357
|
-
|
|
41358
|
-
tags: { deviceId },
|
|
41359
|
-
meta: {
|
|
41360
|
-
camStreamId: audioStream,
|
|
41361
|
-
brokerId: audioBrokerId,
|
|
41362
|
-
selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
|
|
41363
|
-
hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
|
|
41364
|
-
}
|
|
41657
|
+
if (!prev.usable) {
|
|
41658
|
+
this.state.set(nodeId, {
|
|
41659
|
+
usable: false,
|
|
41660
|
+
armed: true
|
|
41365
41661
|
});
|
|
41366
41662
|
return null;
|
|
41367
41663
|
}
|
|
41368
|
-
|
|
41369
|
-
|
|
41370
|
-
|
|
41664
|
+
if (!prev.armed) {
|
|
41665
|
+
this.state.set(nodeId, {
|
|
41666
|
+
usable: true,
|
|
41667
|
+
armed: true
|
|
41668
|
+
});
|
|
41371
41669
|
return null;
|
|
41372
41670
|
}
|
|
41373
|
-
|
|
41374
|
-
|
|
41375
|
-
|
|
41376
|
-
tags: { deviceId },
|
|
41377
|
-
meta: {
|
|
41378
|
-
audioNodeId,
|
|
41379
|
-
isRemote: isRemoteAudio
|
|
41380
|
-
}
|
|
41381
|
-
});
|
|
41382
|
-
const accumulator = new AudioWindowAccumulator(deviceId);
|
|
41383
|
-
const teardown = startAudioChunkPoller({
|
|
41384
|
-
api,
|
|
41385
|
-
brokerId: audioBrokerId,
|
|
41386
|
-
tag: "audio-analyzer",
|
|
41387
|
-
ownerNodeId: this.deps.ingestNode(),
|
|
41388
|
-
logger: this.deps.logger.withTags({ deviceId }),
|
|
41389
|
-
onChunk: async (chunk) => {
|
|
41390
|
-
this.deps.watchdogNote(deviceId, "audio");
|
|
41391
|
-
try {
|
|
41392
|
-
const audioChunkInput = accumulator.push(chunk);
|
|
41393
|
-
if (!audioChunkInput) return;
|
|
41394
|
-
const result = await api.audioAnalyzer.analyseChunk.mutate({
|
|
41395
|
-
chunk: audioChunkInput,
|
|
41396
|
-
settings,
|
|
41397
|
-
...isRemoteAudio ? { nodeId: audioNodeId } : {}
|
|
41398
|
-
});
|
|
41399
|
-
if (!result) return;
|
|
41400
|
-
const frame = buildAudioResultFrame(deviceId, result);
|
|
41401
|
-
this.deps.eventBus.emit({
|
|
41402
|
-
id: `audio-inference-${deviceId}-${Date.now()}`,
|
|
41403
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
41404
|
-
source: {
|
|
41405
|
-
type: "device",
|
|
41406
|
-
id: deviceId,
|
|
41407
|
-
nodeId: "hub",
|
|
41408
|
-
addonId: "pipeline-orchestrator",
|
|
41409
|
-
deviceId
|
|
41410
|
-
},
|
|
41411
|
-
category: EventCategory.PipelineAudioInferenceResult,
|
|
41412
|
-
data: {
|
|
41413
|
-
deviceId,
|
|
41414
|
-
frame,
|
|
41415
|
-
nodeId: "hub"
|
|
41416
|
-
}
|
|
41417
|
-
});
|
|
41418
|
-
} catch (err) {
|
|
41419
|
-
const msg = errMsg(err);
|
|
41420
|
-
this.deps.logger.error("Audio analysis failed", {
|
|
41421
|
-
tags: { deviceId },
|
|
41422
|
-
meta: { error: msg }
|
|
41423
|
-
});
|
|
41424
|
-
}
|
|
41425
|
-
}
|
|
41671
|
+
this.state.set(nodeId, {
|
|
41672
|
+
usable: false,
|
|
41673
|
+
armed: true
|
|
41426
41674
|
});
|
|
41427
|
-
|
|
41428
|
-
return () => {
|
|
41429
|
-
teardown();
|
|
41430
|
-
accumulator.reset();
|
|
41431
|
-
};
|
|
41675
|
+
return "became-unusable";
|
|
41432
41676
|
}
|
|
41433
|
-
/**
|
|
41434
|
-
|
|
41435
|
-
|
|
41436
|
-
|
|
41437
|
-
|
|
41438
|
-
|
|
41439
|
-
|
|
41440
|
-
|
|
41441
|
-
|
|
41442
|
-
|
|
41443
|
-
|
|
41444
|
-
this.
|
|
41677
|
+
/** Can this node be given cameras? Unknown nodes answer YES. */
|
|
41678
|
+
isUsable(nodeId) {
|
|
41679
|
+
return this.state.get(nodeId)?.usable ?? true;
|
|
41680
|
+
}
|
|
41681
|
+
/** Nodes currently excluded — for the placement log and diagnostics. */
|
|
41682
|
+
unusableNodeIds() {
|
|
41683
|
+
const out = [];
|
|
41684
|
+
for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
|
|
41685
|
+
return out.toSorted();
|
|
41686
|
+
}
|
|
41687
|
+
forget(nodeId) {
|
|
41688
|
+
this.state.delete(nodeId);
|
|
41445
41689
|
}
|
|
41690
|
+
reset() {
|
|
41691
|
+
this.state.clear();
|
|
41692
|
+
}
|
|
41693
|
+
};
|
|
41694
|
+
//#endregion
|
|
41695
|
+
//#region src/inference-device-usability-mirror.ts
|
|
41696
|
+
/**
|
|
41697
|
+
* InferenceDeviceUsabilityMirror — "can this NODE's THIS DEVICE still infer?",
|
|
41698
|
+
* kept off the placement path.
|
|
41699
|
+
*
|
|
41700
|
+
* The per-`(nodeId, deviceKey)` twin of {@link NodeInferenceUsabilityMirror},
|
|
41701
|
+
* and deliberately not a second mechanism: it composes the key and delegates
|
|
41702
|
+
* every decision to that class, so the arm/apply reluctance D49 pinned lives in
|
|
41703
|
+
* exactly one implementation and cannot drift between the node tier and the
|
|
41704
|
+
* device tier.
|
|
41705
|
+
*
|
|
41706
|
+
* ## Why this tier had to exist
|
|
41707
|
+
*
|
|
41708
|
+
* The node tier already answers "does this node have ANY usable accelerator".
|
|
41709
|
+
* On 2026-08-25 the hub's answer was, correctly, yes — its NPU was healthy the
|
|
41710
|
+
* whole time. What died was one DEVICE, `openvino:gpu`, and nothing anywhere
|
|
41711
|
+
* asked that question: the per-dispatch capability gate is keyed on model
|
|
41712
|
+
* FORMAT and `openvino:gpu` and `openvino:npu` share the format `openvino`, so
|
|
41713
|
+
* it is blind between them by construction. The balancer kept rotating cameras
|
|
41714
|
+
* onto the dead pool — 20 sessions in 20 minutes, every one `tieBreak:
|
|
41715
|
+
* "rotation"` — for 31 hours.
|
|
41716
|
+
*
|
|
41717
|
+
* ## Why a mirror and not the event
|
|
41718
|
+
*
|
|
41719
|
+
* D49, verbatim: *a one-shot event never gates on a fallible read*. The gate is
|
|
41720
|
+
* this in-memory mirror, refreshed off the event path by the same
|
|
41721
|
+
* `resolveEligibleInferenceDevices` the dispatcher already runs (plus the
|
|
41722
|
+
* session controller's background refresher). The consequences that buys:
|
|
41723
|
+
*
|
|
41724
|
+
* - **A read that fails changes nothing.** The caller folds in an observation
|
|
41725
|
+
* only when it HAS one; an unreachable node, a version-skewed executor or a
|
|
41726
|
+
* rejected RPC never reaches {@link observe}, so the previous verdict
|
|
41727
|
+
* stands. This is the whole reason the health read is specified as
|
|
41728
|
+
* "synchronous over in-memory state, never throws for its own reasons": an
|
|
41729
|
+
* empty answer must mean *nothing is refused*, not *I could not tell*.
|
|
41730
|
+
* - **Excluding a healthy device needs a second, consecutive read.** Exclusion
|
|
41731
|
+
* is the direction that DESTROYS work — it strands an accelerator that may
|
|
41732
|
+
* be perfectly fine — so one bad observation only ARMS.
|
|
41733
|
+
* - **Re-admitting is immediate and unconditional.** One good observation puts
|
|
41734
|
+
* the device straight back. Being slow to exclude costs some wasted
|
|
41735
|
+
* inference attempts; being slow to re-admit costs an idle accelerator and a
|
|
41736
|
+
* node that looks broken.
|
|
41737
|
+
*/
|
|
41738
|
+
/**
|
|
41739
|
+
* NUL — impossible in both a Moleculer node id and a `<backend>:<device>` key,
|
|
41740
|
+
* so the composite key can never be ambiguous. A separator that CAN occur in
|
|
41741
|
+
* either half makes two distinct pairs collide, and a collision here silently
|
|
41742
|
+
* excludes an accelerator nobody reported.
|
|
41743
|
+
*/
|
|
41744
|
+
var SEPARATOR = "\0";
|
|
41745
|
+
var InferenceDeviceUsabilityMirror = class {
|
|
41746
|
+
/** The one implementation of the arm/apply state machine (D49). */
|
|
41747
|
+
mirror = new NodeInferenceUsabilityMirror();
|
|
41446
41748
|
/**
|
|
41447
|
-
*
|
|
41448
|
-
*
|
|
41449
|
-
* session/reconcile/load-shed clears — subscription teardown + lock clear
|
|
41450
|
-
* + assignment-map clears) into one call. Safe to combine: both blocks
|
|
41451
|
-
* are synchronous with no interleaved `await`, and every original
|
|
41452
|
-
* statement between them (`sessionRegistry.clear()`,
|
|
41453
|
-
* `cameraFpsMap.clear()`, `remoteHealthAttempts.clear()`,
|
|
41454
|
-
* `loadShedState.clear()`, `loadShedResumeTimer` cleanup) touches state
|
|
41455
|
-
* fully disjoint from anything audio — so their relative order to each
|
|
41456
|
-
* other is unaffected, and the audio-internal order (timers →
|
|
41457
|
-
* subscriptions → lock → assignment maps) is reproduced exactly.
|
|
41749
|
+
* Fold one observation in and report whether the caller should act.
|
|
41750
|
+
* Never throws.
|
|
41458
41751
|
*/
|
|
41459
|
-
|
|
41460
|
-
|
|
41461
|
-
|
|
41462
|
-
|
|
41463
|
-
|
|
41464
|
-
|
|
41465
|
-
|
|
41466
|
-
|
|
41467
|
-
|
|
41468
|
-
this.
|
|
41469
|
-
|
|
41470
|
-
|
|
41752
|
+
observe(nodeId, deviceKey, usable) {
|
|
41753
|
+
return this.mirror.observe(`${nodeId}${SEPARATOR}${deviceKey}`, usable);
|
|
41754
|
+
}
|
|
41755
|
+
/** Can the balancer put a session on this device? Unknown pairs answer YES. */
|
|
41756
|
+
isUsable(nodeId, deviceKey) {
|
|
41757
|
+
return this.mirror.isUsable(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
41758
|
+
}
|
|
41759
|
+
/** Pairs currently excluded — for the placement log and diagnostics. */
|
|
41760
|
+
unusableDevices() {
|
|
41761
|
+
return this.mirror.unusableNodeIds().map((composite) => {
|
|
41762
|
+
const at = composite.indexOf(SEPARATOR);
|
|
41763
|
+
return {
|
|
41764
|
+
nodeId: composite.slice(0, at),
|
|
41765
|
+
deviceKey: composite.slice(at + 1)
|
|
41766
|
+
};
|
|
41767
|
+
});
|
|
41768
|
+
}
|
|
41769
|
+
/** The excluded device keys on ONE node. */
|
|
41770
|
+
unusableDeviceKeys(nodeId) {
|
|
41771
|
+
return this.unusableDevices().filter((d) => d.nodeId === nodeId).map((d) => d.deviceKey);
|
|
41772
|
+
}
|
|
41773
|
+
forget(nodeId, deviceKey) {
|
|
41774
|
+
this.mirror.forget(`${nodeId}${SEPARATOR}${deviceKey}`);
|
|
41775
|
+
}
|
|
41776
|
+
reset() {
|
|
41777
|
+
this.mirror.reset();
|
|
41471
41778
|
}
|
|
41472
41779
|
};
|
|
41780
|
+
/**
|
|
41781
|
+
* Fold ONE node's health answer into the mirror and return what changed.
|
|
41782
|
+
*
|
|
41783
|
+
* This is the whole reading discipline, in one place, because both halves of it
|
|
41784
|
+
* are easy to get subtly wrong and neither failure is visible in a log:
|
|
41785
|
+
*
|
|
41786
|
+
* - **`unhealthy === null` — the read FAILED — changes nothing.** Not a single
|
|
41787
|
+
* entry is touched. An unreachable node, a version-skewed executor or a
|
|
41788
|
+
* rejected RPC must be distinguishable from "asked, nothing is refused", or
|
|
41789
|
+
* a flaky link silently re-admits a dead accelerator (D49).
|
|
41790
|
+
* - **Every device the node HAS is observed**, not merely the refused ones.
|
|
41791
|
+
* The first draft observed `refused ∪ already-excluded`, which omits exactly
|
|
41792
|
+
* the devices the mirror has ARMED — so their disarming good read never
|
|
41793
|
+
* arrived and two bad reads an HOUR apart, with a hundred healthy ones
|
|
41794
|
+
* between them, excluded a working accelerator. "Consecutive" is only a
|
|
41795
|
+
* property if the good observations are delivered.
|
|
41796
|
+
*
|
|
41797
|
+
* Pure with respect to everything except `mirror`, and never throws — it is
|
|
41798
|
+
* called from the dispatcher's own read path.
|
|
41799
|
+
*/
|
|
41800
|
+
function foldInferenceDeviceHealth(mirror, nodeId, unhealthy, present) {
|
|
41801
|
+
if (unhealthy === null) return [];
|
|
41802
|
+
const refused = new Set(unhealthy);
|
|
41803
|
+
const observed = new Set([
|
|
41804
|
+
...present,
|
|
41805
|
+
...refused,
|
|
41806
|
+
...mirror.unusableDeviceKeys(nodeId)
|
|
41807
|
+
]);
|
|
41808
|
+
const changes = [];
|
|
41809
|
+
for (const deviceKey of observed) {
|
|
41810
|
+
const transition = mirror.observe(nodeId, deviceKey, !refused.has(deviceKey));
|
|
41811
|
+
if (transition !== null) changes.push({
|
|
41812
|
+
deviceKey,
|
|
41813
|
+
transition
|
|
41814
|
+
});
|
|
41815
|
+
}
|
|
41816
|
+
return changes;
|
|
41817
|
+
}
|
|
41473
41818
|
//#endregion
|
|
41474
41819
|
//#region src/load-balancer.ts
|
|
41475
41820
|
/**
|
|
@@ -43405,6 +43750,90 @@ function applyDeviceProvisioning(steps, base, override) {
|
|
|
43405
43750
|
});
|
|
43406
43751
|
}
|
|
43407
43752
|
//#endregion
|
|
43753
|
+
//#region src/device-activity-source.ts
|
|
43754
|
+
/** The source's own name on the wire. One constant, used by every emit + gate. */
|
|
43755
|
+
var DEVICE_ACTIVITY_SOURCE = "device-activity";
|
|
43756
|
+
var DeviceActivitySource = class {
|
|
43757
|
+
#deps;
|
|
43758
|
+
#devices = /* @__PURE__ */ new Map();
|
|
43759
|
+
constructor(deps) {
|
|
43760
|
+
this.#deps = deps;
|
|
43761
|
+
}
|
|
43762
|
+
/**
|
|
43763
|
+
* A `recording-signal` level landed for a device. Idempotent in the level:
|
|
43764
|
+
* only a CHANGE emits an edge, and the sustain tick — not a repeated push —
|
|
43765
|
+
* is what keeps the session open.
|
|
43766
|
+
*/
|
|
43767
|
+
onSignalLevel(deviceId, active, reason, atMs) {
|
|
43768
|
+
const entry = this.#devices.get(deviceId) ?? {
|
|
43769
|
+
active: false,
|
|
43770
|
+
timer: null
|
|
43771
|
+
};
|
|
43772
|
+
const wasActive = entry.active;
|
|
43773
|
+
entry.active = active;
|
|
43774
|
+
this.#devices.set(deviceId, entry);
|
|
43775
|
+
if (active === wasActive) return;
|
|
43776
|
+
if (active) {
|
|
43777
|
+
this.#deps.logger.info("device-activity: the device reports it is working", {
|
|
43778
|
+
tags: { deviceId },
|
|
43779
|
+
meta: { reason }
|
|
43780
|
+
});
|
|
43781
|
+
this.#emit(deviceId, true, atMs);
|
|
43782
|
+
this.#arm(deviceId, entry);
|
|
43783
|
+
return;
|
|
43784
|
+
}
|
|
43785
|
+
this.#clear(entry);
|
|
43786
|
+
this.#deps.logger.info("device-activity: the device reports it has stopped", {
|
|
43787
|
+
tags: { deviceId },
|
|
43788
|
+
meta: { reason }
|
|
43789
|
+
});
|
|
43790
|
+
this.#emit(deviceId, false, atMs);
|
|
43791
|
+
}
|
|
43792
|
+
/** Drop every timer (addon teardown). The mirror goes with the instance. */
|
|
43793
|
+
stop() {
|
|
43794
|
+
for (const entry of this.#devices.values()) this.#clear(entry);
|
|
43795
|
+
this.#devices.clear();
|
|
43796
|
+
}
|
|
43797
|
+
#arm(deviceId, entry) {
|
|
43798
|
+
this.#clear(entry);
|
|
43799
|
+
const sustainMs = this.#deps.sustainMsFor(deviceId);
|
|
43800
|
+
const timer = setInterval(() => {
|
|
43801
|
+
const current = this.#devices.get(deviceId);
|
|
43802
|
+
if (!current || !current.active) {
|
|
43803
|
+
this.#clear(current ?? entry);
|
|
43804
|
+
return;
|
|
43805
|
+
}
|
|
43806
|
+
this.#emit(deviceId, true, Date.now());
|
|
43807
|
+
}, sustainMs);
|
|
43808
|
+
timer.unref?.();
|
|
43809
|
+
entry.timer = timer;
|
|
43810
|
+
}
|
|
43811
|
+
#clear(entry) {
|
|
43812
|
+
if (entry.timer === null) return;
|
|
43813
|
+
clearInterval(entry.timer);
|
|
43814
|
+
entry.timer = null;
|
|
43815
|
+
}
|
|
43816
|
+
/**
|
|
43817
|
+
* Emit, unless this camera does not carry the source. A suppressed emit is
|
|
43818
|
+
* SAID — an operator who never ticked the box and an addon that is quietly
|
|
43819
|
+
* broken look identical from the outside otherwise.
|
|
43820
|
+
*/
|
|
43821
|
+
#emit(deviceId, detected, timestamp) {
|
|
43822
|
+
const sources = this.#deps.motionSourcesFor(deviceId);
|
|
43823
|
+
if (sources === null || !sources.includes("device-activity")) {
|
|
43824
|
+
this.#deps.logger.debug("device-activity: level not forwarded — the camera does not list `device-activity`", {
|
|
43825
|
+
tags: { deviceId },
|
|
43826
|
+
meta: {
|
|
43827
|
+
detected,
|
|
43828
|
+
sources: sources ?? "no-active-detection"
|
|
43829
|
+
}
|
|
43830
|
+
});
|
|
43831
|
+
return;
|
|
43832
|
+
}
|
|
43833
|
+
this.#deps.emitMotion(deviceId, detected, timestamp);
|
|
43834
|
+
}
|
|
43835
|
+
};
|
|
43836
|
+
//#endregion
|
|
43408
43837
|
//#region src/device-detection-settings.ts
|
|
43409
43838
|
/** Read a required string leaf out of the hydrated `flat` schema values. */
|
|
43410
43839
|
function mustString(flat, deviceId, key) {
|
|
@@ -43430,6 +43859,32 @@ function numberOrDefault(flat, key) {
|
|
|
43430
43859
|
const dflt = uiField && "default" in uiField ? uiField.default : void 0;
|
|
43431
43860
|
return typeof dflt === "number" ? dflt : 0;
|
|
43432
43861
|
}
|
|
43862
|
+
/** The activity rate from the hydrated store, or the shipped default. */
|
|
43863
|
+
function activityFps(flat) {
|
|
43864
|
+
const v = flat["activityDetectionFps"];
|
|
43865
|
+
return typeof v === "number" && v > 0 ? v : 1;
|
|
43866
|
+
}
|
|
43867
|
+
/**
|
|
43868
|
+
* The detection rate a session opens at, given WHAT OPENED IT.
|
|
43869
|
+
*
|
|
43870
|
+
* A CAP, not a set: `min(cameraRate, activityRate)`. A camera already slower
|
|
43871
|
+
* than the activity rate stays slower, so lowering the global rate keeps
|
|
43872
|
+
* working. Why this lever and not the other two: a per-device `detectionFps`
|
|
43873
|
+
* would also slow the sessions a real motion trigger opens on the same camera,
|
|
43874
|
+
* and it becomes silently wrong the day the device gains a second trigger; a
|
|
43875
|
+
* per-SOURCE rate table would have to reach the runner and be re-applied
|
|
43876
|
+
* whenever the source holding the session changes, which the attach-time config
|
|
43877
|
+
* cannot express. Scoping it to the session's TRIGGER puts the number exactly
|
|
43878
|
+
* where its justification lives.
|
|
43879
|
+
*
|
|
43880
|
+
* Applies to the session the activity level OPENS. A session already open at
|
|
43881
|
+
* the camera rate when the level rises is not re-attached to slow it down —
|
|
43882
|
+
* re-attaching a live session to change one number costs a decode restart.
|
|
43883
|
+
*/
|
|
43884
|
+
function detectionFpsForTrigger(config, trigger) {
|
|
43885
|
+
if (trigger !== "device-activity") return config.detectionFps;
|
|
43886
|
+
return Math.min(config.detectionFps, config.activityDetectionFps ?? 1);
|
|
43887
|
+
}
|
|
43433
43888
|
/**
|
|
43434
43889
|
* Pure decision/derivation half of the former `resolveDeviceDetectionSettings`.
|
|
43435
43890
|
* Given the I/O-gathered raw materials, resolves every operator-wins →
|
|
@@ -43439,14 +43894,19 @@ function numberOrDefault(flat, key) {
|
|
|
43439
43894
|
* the original method's "narrowing failed" catch.
|
|
43440
43895
|
*/
|
|
43441
43896
|
function resolveDetectionSettings(input) {
|
|
43442
|
-
const { deviceId, raw, flat, features, hasOnboardMotion, pipelineEnabled, motionDetectionEnabled } = input;
|
|
43897
|
+
const { deviceId, raw, flat, features, hasOnboardMotion, hasActivitySignal, pipelineEnabled, motionDetectionEnabled } = input;
|
|
43443
43898
|
const profile = resolveDeviceProfile(features);
|
|
43444
43899
|
const userMotionSources = raw["motionSources"];
|
|
43445
43900
|
let motionSources;
|
|
43446
43901
|
if (userMotionSources !== void 0) motionSources = MotionSourcesSchema.parse(userMotionSources);
|
|
43447
|
-
else
|
|
43448
|
-
|
|
43449
|
-
|
|
43902
|
+
else {
|
|
43903
|
+
let defaulted;
|
|
43904
|
+
if (hasOnboardMotion) defaulted = ["onboard"];
|
|
43905
|
+
else if (hasActivitySignal) defaulted = [];
|
|
43906
|
+
else if (profile && features.includes(DeviceFeature.BatteryOperated)) defaulted = [];
|
|
43907
|
+
else defaulted = MotionSourcesSchema.parse(flat["motionSources"]);
|
|
43908
|
+
motionSources = hasActivitySignal ? [...defaulted, DEVICE_ACTIVITY_SOURCE] : defaulted;
|
|
43909
|
+
}
|
|
43450
43910
|
const userDetectionMode = raw["detectionMode"];
|
|
43451
43911
|
const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
|
|
43452
43912
|
const userAudioMode = raw["audioMode"];
|
|
@@ -43465,6 +43925,7 @@ function resolveDetectionSettings(input) {
|
|
|
43465
43925
|
detectionStreamProfile: mustString(flat, deviceId, "detectionStreamProfile"),
|
|
43466
43926
|
motionFps: numberOrDefault(flat, "motionFps"),
|
|
43467
43927
|
detectionFps: numberOrDefault(flat, "detectionFps"),
|
|
43928
|
+
activityDetectionFps: activityFps(flat),
|
|
43468
43929
|
motionCooldownMs: numberOrDefault(flat, "motionCooldownMs"),
|
|
43469
43930
|
maxSessionHoldMs: numberOrDefault(flat, "maxSessionHoldMs"),
|
|
43470
43931
|
audioMotionWindowMs: numberOrDefault(flat, "audioMotionWindowMs"),
|
|
@@ -43521,6 +43982,7 @@ function buildDetectionConfigFromInputs(resolved, assigned) {
|
|
|
43521
43982
|
detectionStreamId: detectionCamStreamId,
|
|
43522
43983
|
motionFps: resolved.motionFps,
|
|
43523
43984
|
detectionFps: resolved.detectionFps,
|
|
43985
|
+
activityDetectionFps: resolved.activityDetectionFps,
|
|
43524
43986
|
motionCooldownMs: resolved.motionCooldownMs,
|
|
43525
43987
|
maxSessionHoldMs: resolved.maxSessionHoldMs,
|
|
43526
43988
|
audioMotionWindowMs: resolved.audioMotionWindowMs,
|
|
@@ -43546,6 +44008,7 @@ function detectionConfigEquals(a, b) {
|
|
|
43546
44008
|
if (a.detectionStreamId !== b.detectionStreamId) return false;
|
|
43547
44009
|
if (a.motionFps !== b.motionFps) return false;
|
|
43548
44010
|
if (a.detectionFps !== b.detectionFps) return false;
|
|
44011
|
+
if (a.activityDetectionFps !== b.activityDetectionFps) return false;
|
|
43549
44012
|
if (a.motionCooldownMs !== b.motionCooldownMs) return false;
|
|
43550
44013
|
if (a.maxSessionHoldMs !== b.maxSessionHoldMs) return false;
|
|
43551
44014
|
if (a.audioMotionWindowMs !== b.audioMotionWindowMs) return false;
|
|
@@ -44172,6 +44635,7 @@ var DetectionWiringController = class {
|
|
|
44172
44635
|
try {
|
|
44173
44636
|
const features = await this.lookupDeviceFeatures(deviceId);
|
|
44174
44637
|
const hasOnboardMotion = raw["motionSources"] === void 0 ? await this.deps.deviceHasOnboardMotionCap(deviceId) : false;
|
|
44638
|
+
const hasActivitySignal = raw["motionSources"] === void 0 ? await this.deps.deviceHasActivitySignalCap(deviceId) : false;
|
|
44175
44639
|
const pipelineEnabled = await this.isDetectionPipelineActive(deviceId);
|
|
44176
44640
|
const motionDetectionEnabled = await this.isMotionDetectionActive(deviceId);
|
|
44177
44641
|
return resolveDetectionSettings({
|
|
@@ -44180,6 +44644,7 @@ var DetectionWiringController = class {
|
|
|
44180
44644
|
flat,
|
|
44181
44645
|
features,
|
|
44182
44646
|
hasOnboardMotion,
|
|
44647
|
+
hasActivitySignal,
|
|
44183
44648
|
pipelineEnabled,
|
|
44184
44649
|
motionDetectionEnabled
|
|
44185
44650
|
});
|
|
@@ -44785,9 +45250,10 @@ var DeviceConfigContributions = class {
|
|
|
44785
45250
|
const schema = this.deps.deviceSettingsSchema();
|
|
44786
45251
|
if (!schema) return null;
|
|
44787
45252
|
const hasOnboardMotion = await this.deps.deviceHasOnboardMotionCap(input.deviceId);
|
|
44788
|
-
const
|
|
45253
|
+
const hasActivitySignal = await this.deps.deviceHasActivitySignalCap(input.deviceId);
|
|
45254
|
+
const rawWithDefaults = raw["motionSources"] === void 0 && (hasOnboardMotion || hasActivitySignal) ? {
|
|
44789
45255
|
...raw,
|
|
44790
|
-
motionSources: ["onboard"]
|
|
45256
|
+
motionSources: [...hasOnboardMotion ? ["onboard"] : [], ...hasActivitySignal ? ["device-activity"] : []]
|
|
44791
45257
|
} : raw;
|
|
44792
45258
|
const baseSections = hydrateSchema({
|
|
44793
45259
|
...schema,
|
|
@@ -47607,7 +48073,8 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47607
48073
|
if (!isEvent(event, EventCategory.MotionOnMotionChanged)) return;
|
|
47608
48074
|
const { deviceId, detected, timestamp } = event.data;
|
|
47609
48075
|
if (typeof deviceId !== "number") return;
|
|
47610
|
-
|
|
48076
|
+
const parsedSource = MotionSourceEnum.safeParse(event.data.source);
|
|
48077
|
+
deps.handleSessionMotion(deviceId, detected, typeof timestamp === "number" ? timestamp : void 0, parsedSource.success ? parsedSource.data : void 0).catch((err) => {
|
|
47611
48078
|
deps.logger.warn("session motion handler failed", {
|
|
47612
48079
|
tags: { deviceId },
|
|
47613
48080
|
meta: {
|
|
@@ -47629,8 +48096,47 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47629
48096
|
const deviceId = event.source.deviceId;
|
|
47630
48097
|
if (typeof deviceId === "number") deps.noteWatchdogSignal(deviceId, "motion");
|
|
47631
48098
|
});
|
|
48099
|
+
const activitySource = new DeviceActivitySource({
|
|
48100
|
+
logger: deps.logger,
|
|
48101
|
+
emitMotion: (deviceId, detected, timestamp) => {
|
|
48102
|
+
if (isTornDown) return;
|
|
48103
|
+
deps.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, {
|
|
48104
|
+
type: "device",
|
|
48105
|
+
id: deviceId,
|
|
48106
|
+
deviceId
|
|
48107
|
+
}, {
|
|
48108
|
+
deviceId,
|
|
48109
|
+
detected,
|
|
48110
|
+
timestamp,
|
|
48111
|
+
source: DEVICE_ACTIVITY_SOURCE
|
|
48112
|
+
}));
|
|
48113
|
+
},
|
|
48114
|
+
motionSourcesFor: (deviceId) => deps.getActiveDetectionConfig(deviceId)?.motionSources ?? null,
|
|
48115
|
+
sustainMsFor: (deviceId) => {
|
|
48116
|
+
const cooldownMs = deps.getActiveDetectionConfig(deviceId)?.motionCooldownMs;
|
|
48117
|
+
return Math.max(1e3, Math.floor((cooldownMs ?? 3e4) / 2));
|
|
48118
|
+
}
|
|
48119
|
+
});
|
|
48120
|
+
const unsubDeviceActivity = deps.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
48121
|
+
const data = event.data;
|
|
48122
|
+
if (typeof data !== "object" || data === null) return;
|
|
48123
|
+
if (data["capName"] !== recordingSignalCapability.name) return;
|
|
48124
|
+
const deviceId = data["deviceId"];
|
|
48125
|
+
if (typeof deviceId !== "number") return;
|
|
48126
|
+
const level = RecordingSignalStatusSchema.safeParse(data["slice"]);
|
|
48127
|
+
if (!level.success) {
|
|
48128
|
+
deps.logger.warn("device-activity: signal slice does not parse — ignored", {
|
|
48129
|
+
tags: { deviceId },
|
|
48130
|
+
meta: { slice: data["slice"] }
|
|
48131
|
+
});
|
|
48132
|
+
return;
|
|
48133
|
+
}
|
|
48134
|
+
const atMs = event.timestamp instanceof Date ? event.timestamp.getTime() : Date.now();
|
|
48135
|
+
activitySource.onSignalLevel(deviceId, level.data.active, level.data.reason, atMs);
|
|
48136
|
+
});
|
|
47632
48137
|
return () => {
|
|
47633
48138
|
isTornDown = true;
|
|
48139
|
+
activitySource.stop();
|
|
47634
48140
|
for (const t of profileSlotTimers.values()) clearTimeout(t);
|
|
47635
48141
|
profileSlotTimers.clear();
|
|
47636
48142
|
unsubDeviceRegistered();
|
|
@@ -47645,6 +48151,7 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
47645
48151
|
unsubSessionMotion();
|
|
47646
48152
|
unsubFrameTracked();
|
|
47647
48153
|
unsubMotionAnalysis();
|
|
48154
|
+
unsubDeviceActivity();
|
|
47648
48155
|
};
|
|
47649
48156
|
}
|
|
47650
48157
|
//#endregion
|
|
@@ -50404,7 +50911,7 @@ var SessionDispatchController = class {
|
|
|
50404
50911
|
* standing attach, so `hasStandingAttach` stays true for them and
|
|
50405
50912
|
* `decideSessionAction` still returns `'ignore'`.
|
|
50406
50913
|
*/
|
|
50407
|
-
async handleSessionMotion(deviceId, detected, emittedAt) {
|
|
50914
|
+
async handleSessionMotion(deviceId, detected, emittedAt, trigger) {
|
|
50408
50915
|
const receivedAt = Date.now();
|
|
50409
50916
|
const busLagMs = emittedAt !== void 0 ? receivedAt - emittedAt : void 0;
|
|
50410
50917
|
const config = this.deps.getActiveDetectionConfig(deviceId);
|
|
@@ -50439,7 +50946,7 @@ var SessionDispatchController = class {
|
|
|
50439
50946
|
return;
|
|
50440
50947
|
}
|
|
50441
50948
|
this.activeRefireCountByDevice.delete(deviceId);
|
|
50442
|
-
await this.dispatchDetectionSession(deviceId, cur);
|
|
50949
|
+
await this.dispatchDetectionSession(deviceId, cur, trigger);
|
|
50443
50950
|
if (this.sessionRegistry.has(deviceId)) this.scheduleSessionTeardown(deviceId, cooldownMs);
|
|
50444
50951
|
const doneAt = Date.now();
|
|
50445
50952
|
this.deps.logger.info("session motion → attach latency", {
|
|
@@ -50478,7 +50985,7 @@ var SessionDispatchController = class {
|
|
|
50478
50985
|
* `dispatchCamera`) avoids any risk of changing that already-live
|
|
50479
50986
|
* standing-camera path.
|
|
50480
50987
|
*/
|
|
50481
|
-
async dispatchDetectionSession(deviceId, config) {
|
|
50988
|
+
async dispatchDetectionSession(deviceId, config, trigger) {
|
|
50482
50989
|
const log = this.deps.logger.withTags({ deviceId });
|
|
50483
50990
|
await this.deps.reconcilePlacementFromRunners();
|
|
50484
50991
|
const preferredAgent = await this.deps.readPipelinePin(deviceId);
|
|
@@ -50534,13 +51041,19 @@ var SessionDispatchController = class {
|
|
|
50534
51041
|
const steps = applyDeviceProvisioning(pipelineConfig.steps, deviceBase, deviceOverride);
|
|
50535
51042
|
const inferenceDevices = deviceKey && Object.keys(enabledDevices).length >= 2 ? buildInferenceDeviceRoster(enabledDevices, deviceCaps) : void 0;
|
|
50536
51043
|
const zones = await this.deps.listZones(deviceId);
|
|
51044
|
+
const sessionDetectionFps = detectionFpsForTrigger(config, trigger);
|
|
51045
|
+
if (sessionDetectionFps !== config.detectionFps) log.info("session opened at the device-activity rate", { meta: {
|
|
51046
|
+
trigger,
|
|
51047
|
+
detectionFps: sessionDetectionFps,
|
|
51048
|
+
cameraDetectionFps: config.detectionFps
|
|
51049
|
+
} });
|
|
50537
51050
|
const sessionConfig = {
|
|
50538
51051
|
deviceId,
|
|
50539
51052
|
...deviceKey ? { deviceKey } : {},
|
|
50540
51053
|
...inferenceDevices ? { inferenceDevices } : {},
|
|
50541
51054
|
motionCooldownMs: config.motionCooldownMs,
|
|
50542
51055
|
motionFps: config.motionFps,
|
|
50543
|
-
detectionFps:
|
|
51056
|
+
detectionFps: sessionDetectionFps,
|
|
50544
51057
|
motionStreamId: config.motionStreamId,
|
|
50545
51058
|
detectionStreamId: config.detectionStreamId,
|
|
50546
51059
|
motionSources: [],
|
|
@@ -51845,6 +52358,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
51845
52358
|
localNodeId: () => localNodeId,
|
|
51846
52359
|
deviceSettingsSchema: () => deps.deviceSettingsSchema(),
|
|
51847
52360
|
deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
|
|
52361
|
+
deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
|
|
51848
52362
|
deviceHasNativeObjectDetectionCap: (deviceId) => deps.deviceHasNativeObjectDetectionCap(deviceId),
|
|
51849
52363
|
setCameraPipelineForAgent: (input) => deps.setCameraPipelineForAgent(input),
|
|
51850
52364
|
emitCameraUpdated: (deviceId, config) => deps.emitCameraUpdated(deviceId, config),
|
|
@@ -52162,6 +52676,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
52162
52676
|
},
|
|
52163
52677
|
isCapActiveForDevice: (deviceId, capName) => deps.isCapActiveForDevice(deviceId, capName),
|
|
52164
52678
|
deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
|
|
52679
|
+
deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
|
|
52165
52680
|
deviceSettingsSchema: () => deps.deviceSettingsSchema()
|
|
52166
52681
|
});
|
|
52167
52682
|
await detectionWiring.hydrateFeaturesMirror().catch((err) => {
|
|
@@ -52197,255 +52712,6 @@ async function buildOrchestratorControllers(deps) {
|
|
|
52197
52712
|
};
|
|
52198
52713
|
}
|
|
52199
52714
|
//#endregion
|
|
52200
|
-
//#region src/viewer-ui-provider.ts
|
|
52201
|
-
/**
|
|
52202
|
-
* viewer-ui provider — the pipeline-orchestrator serves the CamStack viewer web
|
|
52203
|
-
* SPA (mirrors what the now-removed standalone addon-viewer-ui used to do).
|
|
52204
|
-
*
|
|
52205
|
-
* Why here: the orchestrator is a hub bootstrap addon (always installed + baked),
|
|
52206
|
-
* so folding the viewer serving into it avoids a second addon whose only job was
|
|
52207
|
-
* to hold static files. The viewer's Expo web export is COPIED into this addon's
|
|
52208
|
-
* `assets/viewer/` locally by the viewer repo's `scripts/copy-dist-to-orchestrator.js`
|
|
52209
|
-
* (run from a checkout that HAS the `camstack/` submodule + Expo toolchain — the
|
|
52210
|
-
* publish/image CI does not, which is exactly why we copy a pre-built dist rather
|
|
52211
|
-
* than build it here). vite emits only into `dist/`, so `assets/viewer` survives
|
|
52212
|
-
* the addon build; `assets` is in the package `files`, so it ships on
|
|
52213
|
-
* `camstack deploy`. The hub's `main.ts` resolves the `viewer-ui` singleton at
|
|
52214
|
-
* boot and mounts the SPA at `/viewer/camstack`.
|
|
52215
|
-
*
|
|
52216
|
-
* `index.js` runs from `dist/`, so the SPA root is one level up + `assets/viewer`.
|
|
52217
|
-
*/
|
|
52218
|
-
var __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
52219
|
-
/** Absolute path to the staged viewer web SPA (`<addon-root>/assets/viewer`). */
|
|
52220
|
-
function resolveViewerDistDir() {
|
|
52221
|
-
return path.resolve(__dirname, "..", "assets", "viewer");
|
|
52222
|
-
}
|
|
52223
|
-
/** Version of the staged viewer, written by the copy script; 'unknown' if absent. */
|
|
52224
|
-
function readViewerVersion() {
|
|
52225
|
-
try {
|
|
52226
|
-
const raw = fs.readFileSync(path.join(resolveViewerDistDir(), ".viewer-version"), "utf-8").trim();
|
|
52227
|
-
if (raw) return raw;
|
|
52228
|
-
} catch {}
|
|
52229
|
-
return "unknown";
|
|
52230
|
-
}
|
|
52231
|
-
/** Build the viewer-ui provider. Serves whatever dist was staged into
|
|
52232
|
-
* `assets/viewer`; when nothing is staged the hub's mount cleanly 404s. */
|
|
52233
|
-
function createViewerUiProvider() {
|
|
52234
|
-
return {
|
|
52235
|
-
getStaticDir: async () => ({ staticDir: resolveViewerDistDir() }),
|
|
52236
|
-
getVersion: async () => ({ version: readViewerVersion() })
|
|
52237
|
-
};
|
|
52238
|
-
}
|
|
52239
|
-
//#endregion
|
|
52240
|
-
//#region src/disk-reconcile-fleet.ts
|
|
52241
|
-
async function reconcileFleetFromDisk(deps) {
|
|
52242
|
-
const deviceIds = await deps.listDeviceIds();
|
|
52243
|
-
const failed = [];
|
|
52244
|
-
let cameras = 0;
|
|
52245
|
-
let mediaDropped = 0;
|
|
52246
|
-
let tracks = 0;
|
|
52247
|
-
let events = 0;
|
|
52248
|
-
let completed = 0;
|
|
52249
|
-
for (const deviceId of deviceIds) {
|
|
52250
|
-
try {
|
|
52251
|
-
await deps.rescanRecordings(deviceId);
|
|
52252
|
-
const counts = await deps.reconcileAnalytics(deviceId);
|
|
52253
|
-
cameras += 1;
|
|
52254
|
-
mediaDropped += counts.mediaDropped;
|
|
52255
|
-
tracks += counts.tracks;
|
|
52256
|
-
events += counts.events;
|
|
52257
|
-
} catch {
|
|
52258
|
-
failed.push(deviceId);
|
|
52259
|
-
}
|
|
52260
|
-
completed += 1;
|
|
52261
|
-
deps.onProgress?.({
|
|
52262
|
-
deviceId,
|
|
52263
|
-
total: deviceIds.length,
|
|
52264
|
-
completed,
|
|
52265
|
-
failed: [...failed],
|
|
52266
|
-
mediaDropped,
|
|
52267
|
-
tracks,
|
|
52268
|
-
events
|
|
52269
|
-
});
|
|
52270
|
-
}
|
|
52271
|
-
return {
|
|
52272
|
-
cameras,
|
|
52273
|
-
failed,
|
|
52274
|
-
mediaDropped,
|
|
52275
|
-
tracks,
|
|
52276
|
-
events
|
|
52277
|
-
};
|
|
52278
|
-
}
|
|
52279
|
-
//#endregion
|
|
52280
|
-
//#region src/disk-reconcile-job.ts
|
|
52281
|
-
/**
|
|
52282
|
-
* In-memory disk-wins fleet job. The tRPC mutation starts this and returns
|
|
52283
|
-
* immediately; the walk runs in the addon process so a 60s UDS timeout cannot
|
|
52284
|
-
* abort it. Status is polled via getReconcileFromDiskStatus.
|
|
52285
|
-
*/
|
|
52286
|
-
function idleDiskReconcileJob() {
|
|
52287
|
-
return {
|
|
52288
|
-
state: "idle",
|
|
52289
|
-
total: 0,
|
|
52290
|
-
completed: 0,
|
|
52291
|
-
currentDeviceId: null,
|
|
52292
|
-
failed: [],
|
|
52293
|
-
mediaDropped: 0,
|
|
52294
|
-
tracks: 0,
|
|
52295
|
-
events: 0,
|
|
52296
|
-
startedAtMs: null,
|
|
52297
|
-
finishedAtMs: null,
|
|
52298
|
-
error: null
|
|
52299
|
-
};
|
|
52300
|
-
}
|
|
52301
|
-
function isTimeoutError(err) {
|
|
52302
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
52303
|
-
return /timed out/i.test(message);
|
|
52304
|
-
}
|
|
52305
|
-
async function withTimeoutRetry(run) {
|
|
52306
|
-
try {
|
|
52307
|
-
return await run();
|
|
52308
|
-
} catch (err) {
|
|
52309
|
-
if (!isTimeoutError(err)) throw err;
|
|
52310
|
-
return await run();
|
|
52311
|
-
}
|
|
52312
|
-
}
|
|
52313
|
-
function createDiskReconcileJobRunner(now = Date.now) {
|
|
52314
|
-
let job = idleDiskReconcileJob();
|
|
52315
|
-
let inFlight = null;
|
|
52316
|
-
const snapshot = () => job;
|
|
52317
|
-
const start = (deps) => {
|
|
52318
|
-
if (job.state === "running" && inFlight) return job;
|
|
52319
|
-
job = {
|
|
52320
|
-
...idleDiskReconcileJob(),
|
|
52321
|
-
state: "running",
|
|
52322
|
-
startedAtMs: now()
|
|
52323
|
-
};
|
|
52324
|
-
deps.log?.("pipeline disk reconcile started");
|
|
52325
|
-
inFlight = (async () => {
|
|
52326
|
-
try {
|
|
52327
|
-
const result = await reconcileFleetFromDisk({
|
|
52328
|
-
listDeviceIds: deps.listDeviceIds,
|
|
52329
|
-
rescanRecordings: (deviceId) => withTimeoutRetry(() => deps.rescanRecordings(deviceId)),
|
|
52330
|
-
reconcileAnalytics: (deviceId) => withTimeoutRetry(() => deps.reconcileAnalytics(deviceId)),
|
|
52331
|
-
onProgress: (update) => {
|
|
52332
|
-
job = {
|
|
52333
|
-
...job,
|
|
52334
|
-
total: update.total,
|
|
52335
|
-
completed: update.completed,
|
|
52336
|
-
currentDeviceId: update.deviceId,
|
|
52337
|
-
failed: update.failed,
|
|
52338
|
-
mediaDropped: update.mediaDropped,
|
|
52339
|
-
tracks: update.tracks,
|
|
52340
|
-
events: update.events
|
|
52341
|
-
};
|
|
52342
|
-
deps.onProgress?.(update);
|
|
52343
|
-
deps.log?.("pipeline disk reconcile camera", {
|
|
52344
|
-
deviceId: update.deviceId,
|
|
52345
|
-
completed: update.completed,
|
|
52346
|
-
total: update.total,
|
|
52347
|
-
failed: update.failed.length,
|
|
52348
|
-
mediaDropped: update.mediaDropped,
|
|
52349
|
-
tracks: update.tracks,
|
|
52350
|
-
events: update.events
|
|
52351
|
-
});
|
|
52352
|
-
}
|
|
52353
|
-
});
|
|
52354
|
-
job = {
|
|
52355
|
-
...job,
|
|
52356
|
-
state: "done",
|
|
52357
|
-
total: result.cameras + result.failed.length,
|
|
52358
|
-
completed: result.cameras + result.failed.length,
|
|
52359
|
-
currentDeviceId: null,
|
|
52360
|
-
failed: result.failed,
|
|
52361
|
-
mediaDropped: result.mediaDropped,
|
|
52362
|
-
tracks: result.tracks,
|
|
52363
|
-
events: result.events,
|
|
52364
|
-
finishedAtMs: now(),
|
|
52365
|
-
error: null
|
|
52366
|
-
};
|
|
52367
|
-
deps.log?.("pipeline disk reconcile", {
|
|
52368
|
-
cameras: result.cameras,
|
|
52369
|
-
failed: result.failed,
|
|
52370
|
-
mediaDropped: result.mediaDropped,
|
|
52371
|
-
tracks: result.tracks,
|
|
52372
|
-
events: result.events
|
|
52373
|
-
});
|
|
52374
|
-
} catch (err) {
|
|
52375
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
52376
|
-
job = {
|
|
52377
|
-
...job,
|
|
52378
|
-
state: "error",
|
|
52379
|
-
currentDeviceId: null,
|
|
52380
|
-
finishedAtMs: now(),
|
|
52381
|
-
error
|
|
52382
|
-
};
|
|
52383
|
-
deps.log?.("pipeline disk reconcile failed", { error });
|
|
52384
|
-
} finally {
|
|
52385
|
-
inFlight = null;
|
|
52386
|
-
}
|
|
52387
|
-
})();
|
|
52388
|
-
return job;
|
|
52389
|
-
};
|
|
52390
|
-
return {
|
|
52391
|
-
snapshot,
|
|
52392
|
-
start
|
|
52393
|
-
};
|
|
52394
|
-
}
|
|
52395
|
-
//#endregion
|
|
52396
|
-
//#region src/widget-catalog.ts
|
|
52397
|
-
var pipelineOrchestratorWidgets = [{
|
|
52398
|
-
tab: "device-tab",
|
|
52399
|
-
label: "Pipeline Quick Stats",
|
|
52400
|
-
preAuth: false,
|
|
52401
|
-
kind: "remote",
|
|
52402
|
-
remote: {
|
|
52403
|
-
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
52404
|
-
exposedModule: "./widgets",
|
|
52405
|
-
componentKey: "pipeline-quick-stats"
|
|
52406
|
-
},
|
|
52407
|
-
stableId: "pipeline-quick-stats",
|
|
52408
|
-
description: "Phase / Detection FPS / Inference / Active Tracks tile row.",
|
|
52409
|
-
icon: "activity",
|
|
52410
|
-
bundle: "remoteEntry.js",
|
|
52411
|
-
hosts: ["device-tab", "dashboard"],
|
|
52412
|
-
requires: {
|
|
52413
|
-
deviceContext: true,
|
|
52414
|
-
integrationContext: false
|
|
52415
|
-
},
|
|
52416
|
-
defaultSize: "md",
|
|
52417
|
-
allowedSizes: [
|
|
52418
|
-
"sm",
|
|
52419
|
-
"md",
|
|
52420
|
-
"lg"
|
|
52421
|
-
],
|
|
52422
|
-
defaultColumns: 6,
|
|
52423
|
-
defaultRows: 1
|
|
52424
|
-
}, {
|
|
52425
|
-
tab: "device-tab",
|
|
52426
|
-
label: "Zone Editor",
|
|
52427
|
-
preAuth: false,
|
|
52428
|
-
kind: "remote",
|
|
52429
|
-
remote: {
|
|
52430
|
-
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
52431
|
-
exposedModule: "./widgets",
|
|
52432
|
-
componentKey: "zone-editor"
|
|
52433
|
-
},
|
|
52434
|
-
stableId: "zone-editor",
|
|
52435
|
-
description: "Polygon / tripwire CRUD + per-stage rule editor.",
|
|
52436
|
-
icon: "shapes",
|
|
52437
|
-
bundle: "remoteEntry.js",
|
|
52438
|
-
hosts: ["device-tab"],
|
|
52439
|
-
requires: {
|
|
52440
|
-
deviceContext: true,
|
|
52441
|
-
integrationContext: false
|
|
52442
|
-
},
|
|
52443
|
-
defaultSize: "xl",
|
|
52444
|
-
allowedSizes: ["lg", "xl"],
|
|
52445
|
-
defaultColumns: 12,
|
|
52446
|
-
defaultRows: 4
|
|
52447
|
-
}];
|
|
52448
|
-
//#endregion
|
|
52449
52715
|
//#region src/settings-ui-schemas.ts
|
|
52450
52716
|
/** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
|
|
52451
52717
|
function buildGlobalSettingsSections(options) {
|
|
@@ -52810,6 +53076,22 @@ function buildDeviceSettingsSections(nodeOptions) {
|
|
|
52810
53076
|
field: "detectionMode",
|
|
52811
53077
|
notEquals: "disabled"
|
|
52812
53078
|
}
|
|
53079
|
+
},
|
|
53080
|
+
{
|
|
53081
|
+
key: "activityDetectionFps",
|
|
53082
|
+
type: "slider",
|
|
53083
|
+
label: "Detection FPS while the device is working",
|
|
53084
|
+
min: 1,
|
|
53085
|
+
max: 10,
|
|
53086
|
+
step: 1,
|
|
53087
|
+
default: 1,
|
|
53088
|
+
showValue: true,
|
|
53089
|
+
unit: "fps",
|
|
53090
|
+
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.",
|
|
53091
|
+
showWhen: {
|
|
53092
|
+
field: "motionSources",
|
|
53093
|
+
includes: "device-activity"
|
|
53094
|
+
}
|
|
52813
53095
|
}
|
|
52814
53096
|
]
|
|
52815
53097
|
},
|
|
@@ -52899,6 +53181,99 @@ function deriveRuntimeSettings(config) {
|
|
|
52899
53181
|
};
|
|
52900
53182
|
}
|
|
52901
53183
|
//#endregion
|
|
53184
|
+
//#region src/viewer-ui-provider.ts
|
|
53185
|
+
/**
|
|
53186
|
+
* viewer-ui provider — the pipeline-orchestrator serves the CamStack viewer web
|
|
53187
|
+
* SPA (mirrors what the now-removed standalone addon-viewer-ui used to do).
|
|
53188
|
+
*
|
|
53189
|
+
* Why here: the orchestrator is a hub bootstrap addon (always installed + baked),
|
|
53190
|
+
* so folding the viewer serving into it avoids a second addon whose only job was
|
|
53191
|
+
* to hold static files. The viewer's Expo web export is COPIED into this addon's
|
|
53192
|
+
* `assets/viewer/` locally by the viewer repo's `scripts/copy-dist-to-orchestrator.js`
|
|
53193
|
+
* (run from a checkout that HAS the `camstack/` submodule + Expo toolchain — the
|
|
53194
|
+
* publish/image CI does not, which is exactly why we copy a pre-built dist rather
|
|
53195
|
+
* than build it here). vite emits only into `dist/`, so `assets/viewer` survives
|
|
53196
|
+
* the addon build; `assets` is in the package `files`, so it ships on
|
|
53197
|
+
* `camstack deploy`. The hub's `main.ts` resolves the `viewer-ui` singleton at
|
|
53198
|
+
* boot and mounts the SPA at `/viewer/camstack`.
|
|
53199
|
+
*
|
|
53200
|
+
* `index.js` runs from `dist/`, so the SPA root is one level up + `assets/viewer`.
|
|
53201
|
+
*/
|
|
53202
|
+
var __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
53203
|
+
/** Absolute path to the staged viewer web SPA (`<addon-root>/assets/viewer`). */
|
|
53204
|
+
function resolveViewerDistDir() {
|
|
53205
|
+
return path.resolve(__dirname, "..", "assets", "viewer");
|
|
53206
|
+
}
|
|
53207
|
+
/** Version of the staged viewer, written by the copy script; 'unknown' if absent. */
|
|
53208
|
+
function readViewerVersion() {
|
|
53209
|
+
try {
|
|
53210
|
+
const raw = fs.readFileSync(path.join(resolveViewerDistDir(), ".viewer-version"), "utf-8").trim();
|
|
53211
|
+
if (raw) return raw;
|
|
53212
|
+
} catch {}
|
|
53213
|
+
return "unknown";
|
|
53214
|
+
}
|
|
53215
|
+
/** Build the viewer-ui provider. Serves whatever dist was staged into
|
|
53216
|
+
* `assets/viewer`; when nothing is staged the hub's mount cleanly 404s. */
|
|
53217
|
+
function createViewerUiProvider() {
|
|
53218
|
+
return {
|
|
53219
|
+
getStaticDir: async () => ({ staticDir: resolveViewerDistDir() }),
|
|
53220
|
+
getVersion: async () => ({ version: readViewerVersion() })
|
|
53221
|
+
};
|
|
53222
|
+
}
|
|
53223
|
+
//#endregion
|
|
53224
|
+
//#region src/widget-catalog.ts
|
|
53225
|
+
var pipelineOrchestratorWidgets = [{
|
|
53226
|
+
tab: "device-tab",
|
|
53227
|
+
label: "Pipeline Quick Stats",
|
|
53228
|
+
preAuth: false,
|
|
53229
|
+
kind: "remote",
|
|
53230
|
+
remote: {
|
|
53231
|
+
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
53232
|
+
exposedModule: "./widgets",
|
|
53233
|
+
componentKey: "pipeline-quick-stats"
|
|
53234
|
+
},
|
|
53235
|
+
stableId: "pipeline-quick-stats",
|
|
53236
|
+
description: "Phase / Detection FPS / Inference / Active Tracks tile row.",
|
|
53237
|
+
icon: "activity",
|
|
53238
|
+
bundle: "remoteEntry.js",
|
|
53239
|
+
hosts: ["device-tab", "dashboard"],
|
|
53240
|
+
requires: {
|
|
53241
|
+
deviceContext: true,
|
|
53242
|
+
integrationContext: false
|
|
53243
|
+
},
|
|
53244
|
+
defaultSize: "md",
|
|
53245
|
+
allowedSizes: [
|
|
53246
|
+
"sm",
|
|
53247
|
+
"md",
|
|
53248
|
+
"lg"
|
|
53249
|
+
],
|
|
53250
|
+
defaultColumns: 6,
|
|
53251
|
+
defaultRows: 1
|
|
53252
|
+
}, {
|
|
53253
|
+
tab: "device-tab",
|
|
53254
|
+
label: "Zone Editor",
|
|
53255
|
+
preAuth: false,
|
|
53256
|
+
kind: "remote",
|
|
53257
|
+
remote: {
|
|
53258
|
+
remoteName: "addon_pipeline_orchestrator_widgets",
|
|
53259
|
+
exposedModule: "./widgets",
|
|
53260
|
+
componentKey: "zone-editor"
|
|
53261
|
+
},
|
|
53262
|
+
stableId: "zone-editor",
|
|
53263
|
+
description: "Polygon / tripwire CRUD + per-stage rule editor.",
|
|
53264
|
+
icon: "shapes",
|
|
53265
|
+
bundle: "remoteEntry.js",
|
|
53266
|
+
hosts: ["device-tab"],
|
|
53267
|
+
requires: {
|
|
53268
|
+
deviceContext: true,
|
|
53269
|
+
integrationContext: false
|
|
53270
|
+
},
|
|
53271
|
+
defaultSize: "xl",
|
|
53272
|
+
allowedSizes: ["lg", "xl"],
|
|
53273
|
+
defaultColumns: 12,
|
|
53274
|
+
defaultRows: 4
|
|
53275
|
+
}];
|
|
53276
|
+
//#endregion
|
|
52902
53277
|
//#region src/index.ts
|
|
52903
53278
|
/**
|
|
52904
53279
|
* addon-pipeline-orchestrator — hub-side camera-to-agent load balancer.
|
|
@@ -53213,6 +53588,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
53213
53588
|
isCapActiveForDevice: (deviceId, capName) => this.isCapActiveForDevice(deviceId, capName),
|
|
53214
53589
|
isAudioAnalysisActive: (deviceId) => this.isAudioAnalysisActive(deviceId),
|
|
53215
53590
|
deviceHasOnboardMotionCap: (deviceId) => this.deviceHasOnboardMotionCap(deviceId),
|
|
53591
|
+
deviceHasActivitySignalCap: (deviceId) => this.deviceHasActivitySignalCap(deviceId),
|
|
53216
53592
|
deviceHasNativeObjectDetectionCap: (deviceId) => this.deviceHasNativeObjectDetectionCap(deviceId),
|
|
53217
53593
|
handleDeviceRegistered: (deviceId) => this.handleDeviceRegistered(deviceId),
|
|
53218
53594
|
handleDeviceUnregistered: (deviceId) => this.handleDeviceUnregistered(deviceId),
|
|
@@ -54399,6 +54775,26 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
54399
54775
|
* we err toward the analyzer (never lose detection coverage when the
|
|
54400
54776
|
* binding lookup hiccups).
|
|
54401
54777
|
*/
|
|
54778
|
+
/**
|
|
54779
|
+
* True when the device's driver registered `recording-signal` — the cap a
|
|
54780
|
+
* device uses to say, itself, that it is working (a robot vacuum cleaning).
|
|
54781
|
+
* Drives the `device-activity` entry in the `motionSources` DEFAULT (D392),
|
|
54782
|
+
* so the source arrives on exactly the devices that can raise it and on no
|
|
54783
|
+
* other camera in the fleet.
|
|
54784
|
+
*
|
|
54785
|
+
* Same failure discipline as `deviceHasOnboardMotionCap`: a binding lookup
|
|
54786
|
+
* that throws answers `false`, which loses the DEFAULT and never the
|
|
54787
|
+
* operator's explicit choice (this is asked only when they pinned nothing).
|
|
54788
|
+
*/
|
|
54789
|
+
async deviceHasActivitySignalCap(deviceId) {
|
|
54790
|
+
const api = this.api;
|
|
54791
|
+
if (!api) return false;
|
|
54792
|
+
try {
|
|
54793
|
+
return (await api.deviceManager.getBindings.query({ deviceId })).entries.some((e) => e.kind === "native" && e.capName === "recording-signal");
|
|
54794
|
+
} catch {
|
|
54795
|
+
return false;
|
|
54796
|
+
}
|
|
54797
|
+
}
|
|
54402
54798
|
async deviceHasOnboardMotionCap(deviceId) {
|
|
54403
54799
|
const api = this.api;
|
|
54404
54800
|
if (!api) return false;
|