@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/index.js CHANGED
@@ -30,7 +30,7 @@ node_fs = __toESM(node_fs);
30
30
  let node_path = require("node:path");
31
31
  node_path = __toESM(node_path);
32
32
  let node_url = require("node:url");
33
- //#region ../types/dist/event-category-zAv7pMUz.mjs
33
+ //#region ../types/dist/event-category-CnLqLOKs.mjs
34
34
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
35
35
  EventCategory["SystemBoot"] = "system.boot";
36
36
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -225,6 +225,19 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
225
225
  EventCategory["ProcessCrashed"] = "process.crashed";
226
226
  EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
227
227
  EventCategory["ProcessRestarted"] = "process.restarted";
228
+ /**
229
+ * The SET of storage locations changed — one was created, edited, enabled,
230
+ * disabled or deleted through `storage.upsertLocation` / `deleteLocation`.
231
+ *
232
+ * Telemetry, not a transaction (D8/D11): every consumer that re-resolves on
233
+ * it must also converge on its own periodic path, because a dropped event
234
+ * must not leave a node writing to yesterday's disk set forever. It exists
235
+ * because there was NO signal at all — an operator who added a second
236
+ * recordings disk in the admin UI got nothing, and the recorder kept its
237
+ * resolved locations until something else happened to re-resolve them
238
+ * (D387). Payload `StorageLocationsChangedPayload`.
239
+ */
240
+ EventCategory["StorageLocationsChanged"] = "storage.locations-changed";
228
241
  EventCategory["RecordingStarted"] = "recording.started";
229
242
  EventCategory["RecordingStopped"] = "recording.stopped";
230
243
  EventCategory["RecordingError"] = "recording.error";
@@ -9412,6 +9425,21 @@ var StorageCleanupJobSchema = object({
9412
9425
  });
9413
9426
  var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
9414
9427
  /**
9428
+ * The one typed state of a storage location. Authoritative Zod schema — the TS
9429
+ * alias below is `z.infer<>` of it, never a second spelling.
9430
+ */
9431
+ var StorageLocationModeSchema = _enum([
9432
+ "active",
9433
+ "readonly",
9434
+ "drain",
9435
+ "disabled"
9436
+ ]);
9437
+ _enum([
9438
+ "normal",
9439
+ "never",
9440
+ "drain"
9441
+ ]);
9442
+ /**
9415
9443
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
9416
9444
  * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
9417
9445
  * so the persisted record schema and the consumer-facing cap can both consume it
@@ -9477,6 +9505,21 @@ var StorageLocationSchema = object({
9477
9505
  * stops existing rather than being re-derived on every read.
9478
9506
  */
9479
9507
  enabled: boolean().optional(),
9508
+ /**
9509
+ * THE state of this location (D385), and the only authority on what may be
9510
+ * written, read or evicted here. Interpreted in exactly one place —
9511
+ * `storage-location-mode.ts` — which also folds the legacy
9512
+ * `enabled` / `config.readOnly` pair into a mode so an old row is never
9513
+ * ambiguous.
9514
+ *
9515
+ * OPTIONAL only for the wire and for rows written before D385: absence is
9516
+ * resolved by `resolveLocationMode`, and the orchestrator stamps every
9517
+ * unstamped row ONCE at hydrate so absence stops existing rather than being
9518
+ * re-derived on every read. `enabled` survives one release as a DERIVED
9519
+ * mirror (`mode === 'active'`); `withLocationMode` is the only writer of
9520
+ * either, so the two cannot disagree.
9521
+ */
9522
+ mode: StorageLocationModeSchema.optional(),
9480
9523
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
9481
9524
  * for node-local locations it can reach) — never persisted, absent when the
9482
9525
  * volume is remote/unreachable. The single capacity truth every UI reads. */
@@ -9484,11 +9527,46 @@ var StorageLocationSchema = object({
9484
9527
  totalBytes: number(),
9485
9528
  availableBytes: number()
9486
9529
  }).nullable().optional(),
9530
+ /**
9531
+ * How much of that volume CamStack ITSELF holds on this location (D388) —
9532
+ * COMPUTED at read time from the `storage-occupancy` providers' own figures,
9533
+ * never persisted, never a filesystem walk.
9534
+ *
9535
+ * **ABSENT MEANS UNKNOWN, never zero.** No provider has reported for this
9536
+ * location yet — nobody stores here, the owning addon is down, or the first
9537
+ * refresh has not completed. A UI must omit the segment rather than draw it
9538
+ * at zero, which would claim we occupy nothing (D315). It is an OBJECT and
9539
+ * not a bare number precisely so that a `?? 0` on the consuming side has to
9540
+ * be spelled out loud instead of appearing by accident.
9541
+ *
9542
+ * `measuredAtMs` is the OLDEST contributing measurement, so it is honest
9543
+ * about the whole figure rather than about its freshest part.
9544
+ */
9545
+ owned: object({
9546
+ bytes: number().int().nonnegative(),
9547
+ measuredAtMs: number().int().nonnegative()
9548
+ }).optional(),
9487
9549
  createdAt: number(),
9488
9550
  updatedAt: number()
9489
9551
  });
9490
9552
  object({ isDefault: boolean().optional() });
9491
9553
  /**
9554
+ * How far a `drain` has got (D386) — the read a UI renders, and nothing more.
9555
+ *
9556
+ * `estimatedEmptyAtMs` is derived from the growth the ratchet has actually
9557
+ * OBSERVED and is `null` when it has observed none. Never a fabricated date: a
9558
+ * drain with no observed growth has no honest ETA, and inventing one is how an
9559
+ * operator learns not to believe the screen.
9560
+ */
9561
+ var StorageDrainProgressSchema = object({
9562
+ locationId: string(),
9563
+ startedAtMs: number(),
9564
+ startBytes: number(),
9565
+ bytesRemaining: number(),
9566
+ drained: boolean(),
9567
+ estimatedEmptyAtMs: number().nullable()
9568
+ });
9569
+ /**
9492
9570
  * Reference accepted by consumer-facing `api.storage.*` calls.
9493
9571
  * Either:
9494
9572
  * - a `StorageLocationType` (e.g. `'backups'`) → the sole location of that type
@@ -21232,8 +21310,25 @@ var occupancyRecheckFramesField = {
21232
21310
  * (analyzer attaches detected `regions[]`; onboard does not — the
21233
21311
  * camera typically only reports a binary signal plus an optional
21234
21312
  * channel/AI class which lives in dedicated event channels).
21235
- */
21236
- var MotionSourceEnum = _enum(["onboard", "analyzer"]);
21313
+ *
21314
+ * - `onboard` — the camera's firmware said something moved.
21315
+ * - `analyzer` — this runner's frame-diff said so, and attaches `regions[]`.
21316
+ * - `device-activity` — the DEVICE said it is doing its job: the
21317
+ * `recording-signal` LEVEL the same device raises for the recorder
21318
+ * ([D380](../../../../docs/decisions/adr-0380-a-device-decided-recording-is-a-mode-with-no-schedule-seeded-once.md)),
21319
+ * republished as a motion source. It attaches **nothing** — no regions, no
21320
+ * class: the only fact it carries is that the device is active, and a robot
21321
+ * vacuum that is itself the moving object has no region worth sending. It is
21322
+ * a LEVEL, so unlike `onboard` it has a real falling edge, and unlike
21323
+ * `analyzer` it must not open the frame-diff side-channel — the runner's
21324
+ * `handleOnboardMotionAnalyzer` gate is `source === 'onboard'` and stays that
21325
+ * way ([D392](../../../../docs/decisions/adr-0392-a-device-that-says-it-is-working-is-a-motion-source-of-its-own.md)).
21326
+ */
21327
+ var MotionSourceEnum = _enum([
21328
+ "onboard",
21329
+ "analyzer",
21330
+ "device-activity"
21331
+ ]);
21237
21332
  /**
21238
21333
  * List of motion sources active on a camera. Empty array is valid:
21239
21334
  * "no source" — happens for battery cams without firmware motion when
@@ -21497,13 +21592,20 @@ var RunnerCameraDeviceUIFields = [
21497
21592
  type: "multiselect",
21498
21593
  label: "Motion Sources",
21499
21594
  default: ["analyzer"],
21500
- options: [{
21501
- value: "analyzer",
21502
- label: "Frame-diff Analyzer (motion addon)"
21503
- }, {
21504
- value: "onboard",
21505
- label: "Camera Onboard Sensor"
21506
- }]
21595
+ options: [
21596
+ {
21597
+ value: "analyzer",
21598
+ label: "Frame-diff Analyzer (motion addon)"
21599
+ },
21600
+ {
21601
+ value: "onboard",
21602
+ label: "Camera Onboard Sensor"
21603
+ },
21604
+ {
21605
+ value: "device-activity",
21606
+ label: "Device activity (the device says it is working)"
21607
+ }
21608
+ ]
21507
21609
  },
21508
21610
  {
21509
21611
  key: "motionFps",
@@ -23240,7 +23342,7 @@ method(object({
23240
23342
  }), _void(), {
23241
23343
  kind: "mutation",
23242
23344
  auth: "admin"
23243
- }), method(object({ id: string() }), object({
23345
+ }), method(_void(), array(StorageDrainProgressSchema).readonly()), method(object({ id: string() }), object({
23244
23346
  ok: boolean(),
23245
23347
  error: string().optional()
23246
23348
  }), { auth: "admin" }), method(_void(), array(ProviderListEntrySchema).readonly()), method(object({
@@ -23310,6 +23412,51 @@ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin"
23310
23412
  kind: "mutation",
23311
23413
  auth: "admin"
23312
23414
  }), method(object({}), array(StorageMigrationJobSchema).readonly(), { auth: "admin" });
23415
+ /**
23416
+ * `storage-occupancy` — how many bytes an addon actually HOLDS on a storage
23417
+ * location (D388).
23418
+ *
23419
+ * ## Why this is not `storage-evictable`
23420
+ *
23421
+ * `storage-evictable.getEvictableUsage` looks like the same question and is
23422
+ * not, in two ways that both matter and both bite hardest on the locations an
23423
+ * operator most wants a figure for:
23424
+ *
23425
+ * - it reports the whole eviction DOMAIN, not the location. `recordings:default`
23426
+ * and `recordingsLow:default` deliberately share one root and evict as one
23427
+ * oldest-first pool, so both answer with the SAME combined total. As an
23428
+ * occupancy figure that double-counts the disk.
23429
+ * - it reports ZERO for a location whose eviction policy is `never` (D385) —
23430
+ * a `readonly` or `disabled` disk. Those are exactly the disks an operator
23431
+ * is retiring and staring at.
23432
+ *
23433
+ * So this is its own contract with its own quantity, and the quantity is
23434
+ * OCCUPIED: every byte the addon holds on that location, whether or not it
23435
+ * would ever be willing to delete it. A provider that can only answer
23436
+ * "evictable" must not register here — a number that silently means different
23437
+ * things per class is worse than no number.
23438
+ *
23439
+ * ## Absence is an answer
23440
+ *
23441
+ * A location nobody reports for is UNKNOWN, never zero (D315). The orchestrator
23442
+ * stamps `StorageLocation.owned` only for locations it has a report for, and
23443
+ * the field is an OBJECT rather than a bare number so that a `?? 0` on the
23444
+ * consuming side has to be written out loud instead of appearing by accident.
23445
+ *
23446
+ * `internal: true` — consumed by the orchestrator's `listLocations` stamp, never
23447
+ * a public client surface. Clients read the stamped `StorageLocation.owned`.
23448
+ */
23449
+ /** One provider's occupancy answer for one location. */
23450
+ var StorageOccupancyReportSchema = object({
23451
+ locationId: string(),
23452
+ /** Bytes this provider holds on THAT location — not its eviction domain, and
23453
+ * not net of what it is willing to delete. */
23454
+ ownedBytes: number().int().nonnegative(),
23455
+ /** When the provider last actually measured this. The orchestrator carries it
23456
+ * through so a UI can say how old the figure is instead of implying "now". */
23457
+ measuredAtMs: number().int().nonnegative()
23458
+ });
23459
+ method(object({ locationIds: array(string()).readonly() }), array(StorageOccupancyReportSchema).readonly(), { auth: "admin" });
23313
23460
  var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
23314
23461
  providerId: string().min(1),
23315
23462
  displayName: string().min(1),
@@ -24945,39 +25092,6 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, meth
24945
25092
  deviceId: number(),
24946
25093
  status: BatteryStatusSchema
24947
25094
  });
24948
- /**
24949
- * Network-link snapshot. Same shape for every provider (a Reolink wifi
24950
- * camera, a Home Assistant device with a signal-strength sensor, a Tapo
24951
- * plug): one slice under `device.runtimeState['network-link']`, one badge,
24952
- * one Home Assistant projection.
24953
- */
24954
- var NetworkLinkStatusSchema = object({
24955
- /** The link the device is on. `'unknown'` = not read yet, not "no link". */
24956
- type: _enum([
24957
- "wifi",
24958
- "ethernet",
24959
- "cellular",
24960
- "unknown"
24961
- ]),
24962
- /**
24963
- * Link quality, 0..100 inclusive, normalised by the provider from whatever
24964
- * the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
24965
- * KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
24966
- * one whose reading has not landed must not be drawn at 0 %. Consumers
24967
- * SKIP a null rather than coerce it.
24968
- */
24969
- signalPercent: number().min(0).max(100).nullable(),
24970
- /** Raw received signal strength in dBm, when the firmware reports one. */
24971
- rssiDbm: number().optional(),
24972
- /** Network name of a wireless link, when the firmware reports it. */
24973
- ssid: string().optional(),
24974
- /** Ms epoch of the last observation. Lets consumers reason about freshness. */
24975
- lastUpdated: number()
24976
- });
24977
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
24978
- deviceId: number(),
24979
- status: NetworkLinkStatusSchema
24980
- });
24981
25095
  object({
24982
25096
  on: boolean(),
24983
25097
  /** Ms epoch of the last transition. 0 if never observed. */
@@ -27331,6 +27445,236 @@ DeviceType.Camera, method(object({
27331
27445
  detection: NativeDetectionSchema
27332
27446
  });
27333
27447
  /**
27448
+ * `navigation` — a device-scoped capability that natively expresses the FULL
27449
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
27450
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
27451
+ *
27452
+ * Why a NEW cap rather than overloading `ptz`:
27453
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
27454
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
27455
+ * The two are different physical models: PTZ is absolute-position + presets,
27456
+ * navigation is momentary drive nudges + discrete robot ACTIONS
27457
+ * (dock / spot-clean / follow-pet / go-to-point / …).
27458
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
27459
+ * the reverse:
27460
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
27461
+ * / `getOptions`), and
27462
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
27463
+ * robot camera shows up in the existing PTZ control path without every
27464
+ * PTZ provider learning about robots. The mapping lives in the adapter,
27465
+ * not here (see the addon design note):
27466
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
27467
+ * ptz.stop() → navigation.stop()
27468
+ * ptz.goHome() → navigation.runAction('goHome')
27469
+ * ptz.getPresets() → navigation.listActions() (id→preset)
27470
+ * ptz.goToPreset(id) → navigation.runAction(id)
27471
+ *
27472
+ * ## Continuous drive
27473
+ *
27474
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
27475
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
27476
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
27477
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
27478
+ * coalesce them. The UI owns the cadence.
27479
+ *
27480
+ * ## The action dictionary
27481
+ *
27482
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
27483
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
27484
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
27485
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
27486
+ * vendor-specific list. `kind: 'action'` entries are triggered with
27487
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
27488
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
27489
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
27490
+ *
27491
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
27492
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
27493
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
27494
+ * every device handle. A future nodedreame publish adds a typed
27495
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
27496
+ * provider can then swap the raw calls for the typed methods with no change to
27497
+ * THIS contract.
27498
+ */
27499
+ /**
27500
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
27501
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
27502
+ * halts it.
27503
+ *
27504
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
27505
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
27506
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
27507
+ * vector by it (drivers without proportional drive ignore it).
27508
+ *
27509
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
27510
+ * axis alone; an all-undefined nudge is a no-op.
27511
+ */
27512
+ var NavigationMoveCommandSchema = object({
27513
+ pan: number().min(-1).max(1).optional(),
27514
+ tilt: number().min(-1).max(1).optional(),
27515
+ speed: number().min(0).max(1).optional()
27516
+ });
27517
+ /**
27518
+ * The enumerated discrete actions a navigation-capable robot can perform via
27519
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
27520
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
27521
+ * `playSound` (see the `sound` dictionary entries).
27522
+ */
27523
+ var NavigationActionIdSchema = _enum([
27524
+ "goHome",
27525
+ "locate",
27526
+ "spotClean",
27527
+ "findPet",
27528
+ "personFollow",
27529
+ "stop",
27530
+ "startClean",
27531
+ "pauseClean",
27532
+ "dockWash",
27533
+ "autoEmpty",
27534
+ "flashOn",
27535
+ "flashOff"
27536
+ ]);
27537
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
27538
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
27539
+ /**
27540
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
27541
+ * native panel and the PTZ mimic render as a button.
27542
+ *
27543
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
27544
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
27545
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
27546
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
27547
+ * - `label` — operator-facing English label.
27548
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
27549
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
27550
+ * PTZ render ONLY enabled entries. Data-driven: the provider
27551
+ * flips it from config, never by editing code.
27552
+ */
27553
+ var NavigationActionEntrySchema = object({
27554
+ id: string(),
27555
+ kind: NavigationEntryKindSchema,
27556
+ label: string(),
27557
+ icon: string(),
27558
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
27559
+ soundId: number().int().optional(),
27560
+ /** Per-device feature flag — render this entry only when true. */
27561
+ enabled: boolean()
27562
+ });
27563
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
27564
+ var NavigationPointSchema = object({
27565
+ x: number(),
27566
+ y: number()
27567
+ });
27568
+ /**
27569
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
27570
+ * The cap reports which are enabled so the UI / PTZ render only the controls
27571
+ * that are turned on for THIS device. Data-driven: the provider derives these
27572
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
27573
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
27574
+ * that are not dictionary entries.
27575
+ *
27576
+ * - `move` / `stop` — the momentary drive joystick.
27577
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
27578
+ * map-coordinate plumbing is wired.
27579
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
27580
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
27581
+ * - `light` — the on/off fill-light toggle (works anytime).
27582
+ * - `lightMode` — the auto/manual selector + manual level slider (a
27583
+ * camera-service control; needs an active stream).
27584
+ */
27585
+ var NavigationFeaturesSchema = object({
27586
+ move: boolean(),
27587
+ stop: boolean(),
27588
+ goToPoint: boolean(),
27589
+ runAction: boolean(),
27590
+ playSound: boolean(),
27591
+ light: boolean(),
27592
+ lightMode: boolean()
27593
+ });
27594
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
27595
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
27596
+ /**
27597
+ * Live navigation state so the UI can reflect what the robot is doing:
27598
+ * - `mode` — coarse activity (idle / cleaning / following / …).
27599
+ * - `following` — person/pet follow is currently armed.
27600
+ * - `flash` — the on-camera fill light is on.
27601
+ * - `lightMode` — auto vs manual fill-light mode.
27602
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
27603
+ * `lightMode === 'manual'`.
27604
+ */
27605
+ var NavigationStatusSchema = object({
27606
+ mode: _enum([
27607
+ "idle",
27608
+ "cleaning",
27609
+ "spot",
27610
+ "following",
27611
+ "goto",
27612
+ "returning",
27613
+ "paused",
27614
+ "unknown"
27615
+ ]),
27616
+ following: boolean(),
27617
+ flash: boolean(),
27618
+ lightMode: NavigationLightModeSchema,
27619
+ lightLevel: number().min(40).max(100),
27620
+ /** Ms epoch when the slice was last updated. */
27621
+ lastChangedAt: number()
27622
+ });
27623
+ NavigationStatusSchema.extend({ lastFetchedAt: number() });
27624
+ DeviceType.Camera, method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(NavigationActionEntrySchema)), method(object({
27625
+ deviceId: number(),
27626
+ actionId: NavigationActionIdSchema
27627
+ }), _void(), { kind: "mutation" }), method(object({
27628
+ deviceId: number(),
27629
+ soundId: number().int()
27630
+ }), _void(), { kind: "mutation" }), method(object({
27631
+ deviceId: number(),
27632
+ on: boolean()
27633
+ }), _void(), { kind: "mutation" }), method(object({
27634
+ deviceId: number(),
27635
+ mode: NavigationLightModeSchema,
27636
+ level: number().min(40).max(100).optional()
27637
+ }), _void(), { kind: "mutation" }), method(object({
27638
+ deviceId: number(),
27639
+ level: number().min(40).max(100)
27640
+ }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
27641
+ deviceId: number(),
27642
+ status: NavigationStatusSchema
27643
+ });
27644
+ /**
27645
+ * Network-link snapshot. Same shape for every provider (a Reolink wifi
27646
+ * camera, a Home Assistant device with a signal-strength sensor, a Tapo
27647
+ * plug): one slice under `device.runtimeState['network-link']`, one badge,
27648
+ * one Home Assistant projection.
27649
+ */
27650
+ var NetworkLinkStatusSchema = object({
27651
+ /** The link the device is on. `'unknown'` = not read yet, not "no link". */
27652
+ type: _enum([
27653
+ "wifi",
27654
+ "ethernet",
27655
+ "cellular",
27656
+ "unknown"
27657
+ ]),
27658
+ /**
27659
+ * Link quality, 0..100 inclusive, normalised by the provider from whatever
27660
+ * the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
27661
+ * KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
27662
+ * one whose reading has not landed must not be drawn at 0 %. Consumers
27663
+ * SKIP a null rather than coerce it.
27664
+ */
27665
+ signalPercent: number().min(0).max(100).nullable(),
27666
+ /** Raw received signal strength in dBm, when the firmware reports one. */
27667
+ rssiDbm: number().optional(),
27668
+ /** Network name of a wireless link, when the firmware reports it. */
27669
+ ssid: string().optional(),
27670
+ /** Ms epoch of the last observation. Lets consumers reason about freshness. */
27671
+ lastUpdated: number()
27672
+ });
27673
+ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.Light, DeviceType.Lock, DeviceType.Siren, object({
27674
+ deviceId: number(),
27675
+ status: NetworkLinkStatusSchema
27676
+ });
27677
+ /**
27334
27678
  * network-quality — system-scoped singleton capability tracking RTT,
27335
27679
  * jitter, and observed/peak bandwidth per device + per client.
27336
27680
  *
@@ -28572,203 +28916,6 @@ DeviceType.Camera, method(object({ deviceId: number() }), PtzAutotrackStatusSche
28572
28916
  deviceId: number(),
28573
28917
  status: PtzAutotrackStatusSchema
28574
28918
  });
28575
- /**
28576
- * `navigation` — a device-scoped capability that natively expresses the FULL
28577
- * navigation / action surface of a robot that DRIVES ITSELF and carries an
28578
- * on-board camera (the Dreame robot-vacuum camera is the first provider).
28579
- *
28580
- * Why a NEW cap rather than overloading `ptz`:
28581
- * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
28582
- * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
28583
- * The two are different physical models: PTZ is absolute-position + presets,
28584
- * navigation is momentary drive nudges + discrete robot ACTIONS
28585
- * (dock / spot-clean / follow-pet / go-to-point / …).
28586
- * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
28587
- * the reverse:
28588
- * 1. a native CamStack navigation panel (data-driven from `listActions`
28589
- * / `getOptions`), and
28590
- * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
28591
- * robot camera shows up in the existing PTZ control path without every
28592
- * PTZ provider learning about robots. The mapping lives in the adapter,
28593
- * not here (see the addon design note):
28594
- * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
28595
- * ptz.stop() → navigation.stop()
28596
- * ptz.goHome() → navigation.runAction('goHome')
28597
- * ptz.getPresets() → navigation.listActions() (id→preset)
28598
- * ptz.goToPreset(id) → navigation.runAction(id)
28599
- *
28600
- * ## Continuous drive
28601
- *
28602
- * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
28603
- * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
28604
- * one `stop()` on release — exactly like the robot app's remote-drive joystick.
28605
- * The provider forwards EACH `move` to one drive write; it must NOT debounce or
28606
- * coalesce them. The UI owns the cadence.
28607
- *
28608
- * ## The action dictionary
28609
- *
28610
- * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
28611
- * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
28612
- * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
28613
- * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
28614
- * vendor-specific list. `kind: 'action'` entries are triggered with
28615
- * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
28616
- * (the entry carries the `soundId` to pass). The general primitives — `move`,
28617
- * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
28618
- *
28619
- * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
28620
- * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
28621
- * that the currently-published `@apocaliss92/nodedreame` already exposes on
28622
- * every device handle. A future nodedreame publish adds a typed
28623
- * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
28624
- * provider can then swap the raw calls for the typed methods with no change to
28625
- * THIS contract.
28626
- */
28627
- /**
28628
- * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
28629
- * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
28630
- * halts it.
28631
- *
28632
- * - `pan` — turn: negative = left, positive = right, 0 = straight.
28633
- * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
28634
- * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
28635
- * vector by it (drivers without proportional drive ignore it).
28636
- *
28637
- * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
28638
- * axis alone; an all-undefined nudge is a no-op.
28639
- */
28640
- var NavigationMoveCommandSchema = object({
28641
- pan: number().min(-1).max(1).optional(),
28642
- tilt: number().min(-1).max(1).optional(),
28643
- speed: number().min(0).max(1).optional()
28644
- });
28645
- /**
28646
- * The enumerated discrete actions a navigation-capable robot can perform via
28647
- * `runAction`. This is the CLOSED vocabulary; a given device advertises the
28648
- * subset it supports through `listActions`. Sounds are NOT here — they go through
28649
- * `playSound` (see the `sound` dictionary entries).
28650
- */
28651
- var NavigationActionIdSchema = _enum([
28652
- "goHome",
28653
- "locate",
28654
- "spotClean",
28655
- "findPet",
28656
- "personFollow",
28657
- "stop",
28658
- "startClean",
28659
- "pauseClean",
28660
- "dockWash",
28661
- "autoEmpty",
28662
- "flashOn",
28663
- "flashOff"
28664
- ]);
28665
- /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
28666
- var NavigationEntryKindSchema = _enum(["action", "sound"]);
28667
- /**
28668
- * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
28669
- * native panel and the PTZ mimic render as a button.
28670
- *
28671
- * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
28672
- * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
28673
- * (`sound:meow`) whose `soundId` is passed to `playSound`.
28674
- * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
28675
- * - `label` — operator-facing English label.
28676
- * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
28677
- * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
28678
- * PTZ render ONLY enabled entries. Data-driven: the provider
28679
- * flips it from config, never by editing code.
28680
- */
28681
- var NavigationActionEntrySchema = object({
28682
- id: string(),
28683
- kind: NavigationEntryKindSchema,
28684
- label: string(),
28685
- icon: string(),
28686
- /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
28687
- soundId: number().int().optional(),
28688
- /** Per-device feature flag — render this entry only when true. */
28689
- enabled: boolean()
28690
- });
28691
- /** Coordinates for `goToPoint` — a point on the robot's live map. */
28692
- var NavigationPointSchema = object({
28693
- x: number(),
28694
- y: number()
28695
- });
28696
- /**
28697
- * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
28698
- * The cap reports which are enabled so the UI / PTZ render only the controls
28699
- * that are turned on for THIS device. Data-driven: the provider derives these
28700
- * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
28701
- * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
28702
- * that are not dictionary entries.
28703
- *
28704
- * - `move` / `stop` — the momentary drive joystick.
28705
- * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
28706
- * map-coordinate plumbing is wired.
28707
- * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
28708
- * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
28709
- * - `light` — the on/off fill-light toggle (works anytime).
28710
- * - `lightMode` — the auto/manual selector + manual level slider (a
28711
- * camera-service control; needs an active stream).
28712
- */
28713
- var NavigationFeaturesSchema = object({
28714
- move: boolean(),
28715
- stop: boolean(),
28716
- goToPoint: boolean(),
28717
- runAction: boolean(),
28718
- playSound: boolean(),
28719
- light: boolean(),
28720
- lightMode: boolean()
28721
- });
28722
- /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
28723
- var NavigationLightModeSchema = _enum(["auto", "manual"]);
28724
- /**
28725
- * Live navigation state so the UI can reflect what the robot is doing:
28726
- * - `mode` — coarse activity (idle / cleaning / following / …).
28727
- * - `following` — person/pet follow is currently armed.
28728
- * - `flash` — the on-camera fill light is on.
28729
- * - `lightMode` — auto vs manual fill-light mode.
28730
- * - `lightLevel` — manual fill-light level (40..100); meaningful when
28731
- * `lightMode === 'manual'`.
28732
- */
28733
- var NavigationStatusSchema = object({
28734
- mode: _enum([
28735
- "idle",
28736
- "cleaning",
28737
- "spot",
28738
- "following",
28739
- "goto",
28740
- "returning",
28741
- "paused",
28742
- "unknown"
28743
- ]),
28744
- following: boolean(),
28745
- flash: boolean(),
28746
- lightMode: NavigationLightModeSchema,
28747
- lightLevel: number().min(40).max(100),
28748
- /** Ms epoch when the slice was last updated. */
28749
- lastChangedAt: number()
28750
- });
28751
- NavigationStatusSchema.extend({ lastFetchedAt: number() });
28752
- DeviceType.Camera, method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(NavigationActionEntrySchema)), method(object({
28753
- deviceId: number(),
28754
- actionId: NavigationActionIdSchema
28755
- }), _void(), { kind: "mutation" }), method(object({
28756
- deviceId: number(),
28757
- soundId: number().int()
28758
- }), _void(), { kind: "mutation" }), method(object({
28759
- deviceId: number(),
28760
- on: boolean()
28761
- }), _void(), { kind: "mutation" }), method(object({
28762
- deviceId: number(),
28763
- mode: NavigationLightModeSchema,
28764
- level: number().min(40).max(100).optional()
28765
- }), _void(), { kind: "mutation" }), method(object({
28766
- deviceId: number(),
28767
- level: number().min(40).max(100)
28768
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), NavigationFeaturesSchema), object({
28769
- deviceId: number(),
28770
- status: NavigationStatusSchema
28771
- });
28772
28919
  DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
28773
28920
  kind: "mutation",
28774
28921
  auth: "admin"
@@ -29455,8 +29602,38 @@ var RecordingSignalStatusSchema = object({
29455
29602
  /** Ms epoch of the last `active` transition. 0 if never observed. */
29456
29603
  lastChangedAt: number()
29457
29604
  });
29458
- RecordingSignalStatusSchema.extend({ lastFetchedAt: number() });
29459
- DeviceType.Camera, method(object({ deviceId: number() }), RecordingSignalStatusSchema);
29605
+ /** The runtime-state slice: the status plus the clock every slice carries. */
29606
+ var RecordingSignalRuntimeStateSchema = RecordingSignalStatusSchema.extend({ lastFetchedAt: number() });
29607
+ var recordingSignalCapability = {
29608
+ name: "recording-signal",
29609
+ scope: "device",
29610
+ deviceNative: true,
29611
+ mode: "singleton",
29612
+ deviceTypes: [DeviceType.Camera],
29613
+ methods: {
29614
+ /** The current level, straight from the slice the provider keeps fresh. */
29615
+ getStatus: method(object({ deviceId: number() }), RecordingSignalStatusSchema) },
29616
+ status: {
29617
+ schema: RecordingSignalStatusSchema,
29618
+ kind: "push",
29619
+ empty: {
29620
+ active: false,
29621
+ reason: "unknown",
29622
+ lastChangedAt: 0
29623
+ }
29624
+ },
29625
+ runtimeState: RecordingSignalRuntimeStateSchema,
29626
+ /**
29627
+ * Runtime-state durability: **session** — a restored `active: true` from
29628
+ * before a restart is exactly the stale level the recorder's reconcile bound
29629
+ * exists to end, and the provider re-derives the true level on activation
29630
+ * anyway. Nothing is lost by forgetting it; a lie is avoided.
29631
+ *
29632
+ * See `RuntimeStateDurability`. Enforced by
29633
+ * `scripts/check-runtime-state-durability.ts`.
29634
+ */
29635
+ durability: "session"
29636
+ };
29460
29637
  /**
29461
29638
  * scene-monitor — device-scoped reference-region state cap. An operator marks
29462
29639
  * a rect ROI on a camera frame and names one or more states; the engine
@@ -36167,6 +36344,12 @@ Object.freeze({
36167
36344
  addonId: null,
36168
36345
  access: "view"
36169
36346
  },
36347
+ "storage.listDrainProgress": {
36348
+ capName: "storage",
36349
+ capScope: "system",
36350
+ addonId: null,
36351
+ access: "view"
36352
+ },
36170
36353
  "storage.listLocationDeclarations": {
36171
36354
  capName: "storage",
36172
36355
  capScope: "system",
@@ -36311,6 +36494,12 @@ Object.freeze({
36311
36494
  addonId: null,
36312
36495
  access: "view"
36313
36496
  },
36497
+ "storageOccupancy.getOccupancy": {
36498
+ capName: "storage-occupancy",
36499
+ capScope: "system",
36500
+ addonId: null,
36501
+ access: "view"
36502
+ },
36314
36503
  "storageProvider.abortUpload": {
36315
36504
  capName: "storage-provider",
36316
36505
  capScope: "system",
@@ -39920,702 +40109,113 @@ function deviceBackendToFormat(backend) {
39920
40109
  return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
39921
40110
  }
39922
40111
  //#endregion
39923
- //#region src/inference-device-model.ts
40112
+ //#region src/audio-chunk-poller.ts
39924
40113
  /**
39925
- * Per-device default object-detection model + deviceKey parsing for the
39926
- * orchestrator's device-aware `getNodeInferenceDevices` view.
40114
+ * `AudioChunkPoller` the consumer-side poll loop of the decoded audio-chunk
40115
+ * plane (Phase 5 / D9).
39927
40116
  *
39928
- * This DUPLICATES the executor's per-device model resolution (P0-3:
39929
- * `resolveDeviceEngine` + `MODEL_BY_CLASS` + the object-detection step's
39930
- * `defaultModelIdByFormat` in `@camstack/addon-pipeline`). It is duplicated
39931
- * not imported because cross-addon imports are forbidden (the orchestrator
39932
- * and the detection-pipeline are separate addons; only tRPC crosses the
39933
- * boundary). Keep this in sync with `default-detection-model.ts` /
39934
- * `step-definitions.ts` if the executor's defaults change.
40117
+ * Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
40118
+ * path. A live callback cannot cross a process boundary; once the `pipeline`
40119
+ * group is dissolved (Task 8) the orchestrator runs in a different process
40120
+ * from the broker, so audio delivery must go over tRPC.
39935
40121
  *
39936
- * The returned ids are honest catalog ids (verified present):
39937
- * - `yolov9m-320-int8` — Intel NPU + iGPU (yolo26 does NOT compile on the NPU)
39938
- * - `yolov9m-320` — Apple ANE (CoreML)
39939
- * - `ssd-mobilenet-v2-coco-edgetpu` — Coral USB Edge TPU (tflite)
39940
- * - `yolo26n` — CPU / CUDA (the object-detection step's universal
39941
- * nano default)
40122
+ * The consumer:
39942
40123
  *
39943
- * Do not promote the accelerated ids to 640. The evaluation in
39944
- * `docs/benchmarks/pipeline-frame-model-eval.md` failed the 640 promotion
39945
- * gates (0/3 miss recovered at the current threshold).
39946
- */
39947
- /**
39948
- * The always-on object-detection ROOT step id. A camera session's tracks all
39949
- * originate from this detector, so a device whose engine format can't run it
39950
- * cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
39951
- * import is forbidden — this is the same duplication rationale as the model
39952
- * defaults above).
39953
- */
39954
- var OBJECT_DETECTION_STEP_ID = "object-detection";
39955
- /**
39956
- * Build the camera-root capability predicate for a node from its live catalog:
39957
- * `format → canHostCameraRoot`. A format can host a camera root iff the
39958
- * catalog lists at least one object-detection model with a build for that
39959
- * format — byte-for-byte the resolver's per-device skip-gate test for the root
39960
- * step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
39961
- * would ACTUALLY provision on it.
40124
+ * 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC the broker
40125
+ * registers a per-subscription bounded FIFO queue and returns a
40126
+ * `subscriptionId`;
40127
+ * 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
40128
+ * interval, draining `DecodedAudioChunk`s in FIFO arrival order;
40129
+ * 3. feeds each chunk to its downstream audio logic;
40130
+ * 4. on teardown, `unsubscribeAudioChunks`.
39962
40131
  *
39963
- * Fails OPEN when the catalog has no object-detection slot at all (never
39964
- * observed in production) so a malformed/empty catalog never strands every
39965
- * device off the balancer. Pure + deterministic.
40132
+ * Audio is not latency-critical like video, and chunks arrive only ~every
40133
+ * 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
40134
+ * a small per-poll burst keeps latency low without busy-spinning. The
40135
+ * broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
40136
+ * loses a chunk.
40137
+ *
40138
+ * Boot-race tolerance: the broker for a given camStream may not be registered
40139
+ * yet when the orchestrator wires the subscription (provider addons publish
40140
+ * their cameraStreams asynchronously after their probe completes).
40141
+ * `subscribeAudioChunks` retries with exponential backoff (capped at
40142
+ * `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
40143
+ * teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
40144
+ * shape so video and audio plumbing self-heal identically.
39966
40145
  */
39967
- function makeRootCapabilityGuard(catalog) {
39968
- for (const slot of catalog.slots) {
39969
- const objDet = slot.addons.find((a) => a.id === OBJECT_DETECTION_STEP_ID);
39970
- if (objDet) return (format) => objDet.models.some((m) => Boolean(m.formats[format]));
39971
- }
39972
- return () => true;
39973
- }
39974
- /** Split a deviceKey (`<backend>:<device>`, or bare `cpu`) into its parts + format.
39975
- * Format comes from the shared {@link deviceBackendToFormat} SSOT (`@camstack/types`)
39976
- * — the previously-local `BACKEND_FORMAT` copy is gone (R3/node-F2). Used only for
39977
- * STORED-ONLY keys (a configured device the live probe didn't return); a probed
39978
- * device carries its own honest `format` from the descriptor. */
39979
- function parseDeviceKey(deviceKey) {
39980
- const colon = deviceKey.indexOf(":");
39981
- const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
39982
- return {
39983
- backend,
39984
- device: colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey,
39985
- format: deviceBackendToFormat(backend)
39986
- };
39987
- }
40146
+ /** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
40147
+ var POLL_INTERVAL_MS$1 = 200;
40148
+ /** How many chunks to drain per poll — a small burst absorbs jitter. */
40149
+ var PULL_MAX_COUNT = 8;
39988
40150
  /**
39989
- * The object-detection model the executor defaults to for a deviceKey. Mirrors
39990
- * the executor's `MODEL_BY_CLASS` classification (`classifyAccelerator`) plus
39991
- * the tflite `defaultModelIdByFormat` for Coral. Never throws; unknown backends
39992
- * fall back to the universal nano default (`yolo26n`).
40151
+ * Consecutive pull failures before we attempt to re-subscribe. A single failed
40152
+ * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
40153
+ * sustained failure means the broker child restarted and dropped our
40154
+ * subscription, so we re-establish it.
39993
40155
  */
39994
- function defaultModelIdForDevice(deviceKey) {
39995
- const { backend, device } = parseDeviceKey(deviceKey);
39996
- if (backend === "openvino") {
39997
- if (device === "cpu") return "yolo26n";
39998
- return "yolov9m-320-int8";
39999
- }
40000
- if (backend === "edgetpu") return "ssd-mobilenet-v2-coco-edgetpu";
40001
- if (backend === "coreml") return "yolov9m-320";
40002
- return "yolo26n";
40003
- }
40156
+ var RESUBSCRIBE_AFTER_FAILURES = 2;
40157
+ /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
40158
+ var RESUBSCRIBE_THROTTLE_TICKS = 5;
40159
+ /** First subscribe-retry delay, doubled on every subsequent failure. */
40160
+ var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
40004
40161
  /**
40005
- * Step-tree device jump (phase 1): validate every `steps[step].jumpDeviceKey`
40006
- * manual override in a to-be-saved `inferenceDevices` map. A jump target MUST be
40007
- * an enabled∧available device on the SAME node and DIFFERENT from the owning
40008
- * device. `enabledAvailableKeys` is the effective enabled∧available set (from
40009
- * `mergeInferenceDevices(probe, submitted)`) so an absent/unplugged/disabled
40010
- * target is rejected honestly (an operator can't route a step onto a dead pool).
40011
- * Returns the FIRST human-readable error, or `null` when every override is
40012
- * valid. Pure + deterministic.
40162
+ * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
40163
+ * enough to recover within a single reconcile of the orchestrator and slow
40164
+ * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
40013
40165
  */
40014
- function validateJumpTargets(inferenceDevices, enabledAvailableKeys) {
40015
- for (const [deviceKey, entry] of Object.entries(inferenceDevices)) for (const [stepId, step] of Object.entries(entry.steps ?? {})) {
40016
- const target = step.jumpDeviceKey;
40017
- if (target === void 0) continue;
40018
- if (target === deviceKey) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey pointing at its own device`;
40019
- if (!enabledAvailableKeys.has(target)) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey "${target}" that is not an enabled, available device on this node`;
40020
- }
40021
- return null;
40022
- }
40166
+ var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
40023
40167
  /**
40024
- * Merge a node's live-probed inference devices with its stored per-device map.
40025
- *
40026
- * The default is **AUTO = all discovered ACCELERATORS enabled** (spec C2,
40027
- * opt-OUT) with TWO deliberate exceptions, both **opt-IN** (default disabled):
40028
- *
40029
- * - **CPU**: `enumerateInferenceDevices` always emits a universal `cpu`
40030
- * floor on every platform; auto-enabling it would let the balancer
40031
- * round-robin ~1/N of sessions onto the slow CPU pool alongside the
40032
- * NPU/iGPU/ANE. CPU stays the always-available FALLBACK (a node with no
40033
- * eligible accelerator leaves `deviceKey` unset → the runner's default
40034
- * pool, which is CPU), not a balanced target — matching the spec's "no
40035
- * device eligible → fall back to CPU".
40036
- * - **Coral Edge TPU (`edgetpu`)**: the standing rule since the Coral
40037
- * executor landed is that it surfaces as selectable but is NEVER
40038
- * auto-picked — it runs a DIFFERENT, weaker model family (tflite SSD
40039
- * MobileNet, not the YOLO the other accelerators run), so silently
40040
- * enrolling a plugged-in Coral changes detection QUALITY, not just
40041
- * placement. The opt-OUT default did exactly that on 2026-08-01: a hub
40042
- * Coral nobody enabled entered the session rotation and camera 615 spent
40043
- * hours at 2.4fps failing tflite model resolution. An operator who wants
40044
- * the Coral balanced opts it in explicitly (`enabled: true`).
40045
- *
40046
- * So: an NPU/iGPU/ANE accelerator with NO stored entry is `enabled:true`; a
40047
- * CPU or edgetpu device with no stored entry is `enabled:false`; an explicit
40048
- * stored `enabled` always wins (an operator can opt CPU/Coral in, or an
40049
- * accelerator out). A stored-only key (configured but the probe did not
40050
- * return it — removed/unplugged HW) keeps its stored `enabled` and surfaces
40051
- * as `available:false`, so the UI still shows it.
40052
- *
40053
- * Pure + deterministic (sorted by key) — the single merge authority shared by
40054
- * the `getNodeInferenceDevices` view and the dispatcher's eligible-device pick.
40168
+ * Attempts after which a still-failing subscribe escalates from the fast 5 s
40169
+ * ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
40170
+ * minute of fast retries plenty for the boot races the 5 s ceiling exists
40171
+ * for. A broker that is STILL absent after that is a long-lived condition
40172
+ * (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
40173
+ * to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
40174
+ * churn. The slow loop stays alive so audio still recovers automatically
40175
+ * (≤60 s) once the camera is re-enabled.
40055
40176
  */
40056
- function mergeInferenceDevices(probed, stored) {
40057
- const probedByKey = new Map(probed.map((d) => [d.key, d]));
40058
- const keys = new Set([...probedByKey.keys(), ...Object.keys(stored)]);
40059
- const out = [];
40060
- for (const key of Array.from(keys).toSorted()) {
40061
- const descriptor = probedByKey.get(key);
40062
- const opt = stored[key];
40063
- const parsed = descriptor ?? parseDeviceKey(key);
40064
- const weight = opt?.weight !== void 0 && opt.weight > 0 ? opt.weight : 1;
40065
- const autoDefault = parsed.backend !== "cpu" && parsed.backend !== "edgetpu";
40066
- out.push({
40067
- key,
40068
- backend: parsed.backend,
40069
- device: parsed.device,
40070
- format: parsed.format,
40071
- available: descriptor?.available ?? false,
40072
- enabled: opt?.enabled ?? autoDefault,
40073
- weight,
40074
- maxSessions: opt?.maxSessions ?? null,
40075
- defaultModelId: defaultModelIdForDevice(key),
40076
- ...opt?.steps && Object.keys(opt.steps).length > 0 ? { steps: { ...opt.steps } } : {}
40077
- });
40078
- }
40079
- return out;
40080
- }
40177
+ var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
40178
+ var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
40081
40179
  /**
40082
- * The per-device concurrent-session caps for a node as `deviceKey maxSessions`
40083
- * (only devices that carry an explicit cap; absent = unlimited). Fed to the
40084
- * device balancer's `nodeCaps` so a device at its cap is skipped (audit F3).
40180
+ * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
40181
+ *
40182
+ * Always resolves to a teardown closure when the broker is not yet
40183
+ * registered the closure cancels the ongoing retry loop; when polling is
40184
+ * active it stops the loop and releases the broker subscription. Mirrors
40185
+ * `startFrameHandlePoller` so video and audio recover identically.
40085
40186
  */
40086
- function inferenceDeviceCaps(stored) {
40087
- const out = {};
40088
- for (const [key, entry] of Object.entries(stored)) if (entry.maxSessions !== void 0 && entry.maxSessions > 0) out[key] = entry.maxSessions;
40089
- return out;
40090
- }
40091
- /** Is this device the CPU fallback rather than a real accelerator? */
40092
- function isCpuFallback(view) {
40093
- return view.backend === "cpu";
40094
- }
40095
- function resolveInferenceDeviceEligibility(probed, stored, canRunRoot, isPoolUsable) {
40096
- const eligible = {};
40097
- const excluded = [];
40098
- const merged = mergeInferenceDevices(probed, stored);
40099
- const acceleratorServes = merged.some((d) => !isCpuFallback(d) && d.enabled && d.available && (!canRunRoot || canRunRoot(d.format)));
40100
- for (const d of merged) {
40101
- if (!d.enabled) {
40102
- excluded.push({
40103
- key: d.key,
40104
- reason: "disabled",
40105
- format: d.format
40106
- });
40107
- continue;
40108
- }
40109
- if (!d.available) {
40110
- excluded.push({
40111
- key: d.key,
40112
- reason: "unavailable",
40113
- format: d.format
40114
- });
40115
- continue;
40116
- }
40117
- if (isPoolUsable && !isPoolUsable(d.key)) {
40118
- excluded.push({
40119
- key: d.key,
40120
- reason: "unavailable",
40121
- format: d.format
40122
- });
40123
- continue;
40187
+ function startAudioChunkPoller(options) {
40188
+ const lifecycle = {
40189
+ stopped: false,
40190
+ retryTimer: void 0,
40191
+ pollTimer: void 0,
40192
+ activeSubscriptionId: null
40193
+ };
40194
+ const teardown = () => {
40195
+ if (lifecycle.stopped) return;
40196
+ lifecycle.stopped = true;
40197
+ if (lifecycle.retryTimer) {
40198
+ clearTimeout(lifecycle.retryTimer);
40199
+ lifecycle.retryTimer = void 0;
40124
40200
  }
40125
- if (canRunRoot && !canRunRoot(d.format)) {
40126
- excluded.push({
40127
- key: d.key,
40128
- reason: "cannot-host-camera-root",
40129
- format: d.format
40130
- });
40131
- continue;
40201
+ if (lifecycle.pollTimer) {
40202
+ clearTimeout(lifecycle.pollTimer);
40203
+ lifecycle.pollTimer = void 0;
40132
40204
  }
40133
- if (isCpuFallback(d) && acceleratorServes) {
40134
- excluded.push({
40135
- key: d.key,
40136
- reason: "accelerator-preferred",
40137
- format: d.format
40205
+ const subId = lifecycle.activeSubscriptionId;
40206
+ if (subId) {
40207
+ lifecycle.activeSubscriptionId = null;
40208
+ options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
40209
+ options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
40210
+ brokerId: options.brokerId,
40211
+ subscriptionId: subId,
40212
+ error: errMsg(err)
40213
+ } });
40138
40214
  });
40139
- continue;
40140
40215
  }
40141
- eligible[d.key] = d.weight;
40142
- }
40143
- return {
40144
- eligible,
40145
- excluded
40146
40216
  };
40147
- }
40148
- /**
40149
- * Join the merged device rows with the eligibility verdict so the UI can NAME
40150
- * why an accelerator is not in play instead of leaving the operator to deduce
40151
- * it from `enabled`/`available`.
40152
- *
40153
- * Deduction is not possible for two of the four reasons — `accelerator-preferred`
40154
- * is a node-WIDE rule (a CPU row reads `enabled:true, available:true` and still
40155
- * never gets a session, D215) and `cannot-host-camera-root` needs the node's
40156
- * model catalog. Both live in {@link resolveInferenceDeviceEligibility}, so this
40157
- * function only transports its answer; it never re-derives one.
40158
- *
40159
- * Pure; preserves `merged`'s order (sorted by key) and every other field.
40160
- */
40161
- function annotateInferenceDeviceExclusions(merged, eligibility) {
40162
- const reasonByKey = new Map(eligibility.excluded.map((e) => [e.key, e.reason]));
40163
- return merged.map((view) => ({
40164
- ...view,
40165
- exclusion: reasonByKey.get(view.key) ?? null
40166
- }));
40167
- }
40168
- function resolveNodeInferenceUsability(eligibility) {
40169
- const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
40170
- const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
40171
- return {
40172
- usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
40173
- unavailableKeys,
40174
- eligibleKeys
40175
- };
40176
- }
40177
- /**
40178
- * Step-tree device jump (phase 1): the attach-payload roster of a node's
40179
- * enabled∧available inference devices with the balancer knobs (`weight`,
40180
- * `maxSessions`) the runner uses to AUTO-jump an enrichment step off a device
40181
- * whose format can't run it. Built from the SAME `eligible` (deviceKey→weight)
40182
- * and `caps` (deviceKey→maxSessions) the dispatcher already computes, so the
40183
- * roster the runner sees exactly matches the balancer's candidate set. Sorted
40184
- * by key for determinism. Populated onto `RunnerCameraConfig.inferenceDevices`
40185
- * ONLY when a `deviceKey` is elected and there are ≥2 entries.
40186
- */
40187
- function buildInferenceDeviceRoster(eligible, caps) {
40188
- return Object.entries(eligible).map(([deviceKey, weight]) => ({
40189
- deviceKey,
40190
- weight: weight > 0 ? weight : 1,
40191
- maxSessions: caps[deviceKey] ?? null
40192
- })).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
40193
- }
40194
- //#endregion
40195
- //#region src/node-inference-usability-mirror.ts
40196
- var NodeInferenceUsabilityMirror = class {
40197
- state = /* @__PURE__ */ new Map();
40198
- /**
40199
- * Fold one observation in and report whether the caller should act.
40200
- * Never throws.
40201
- */
40202
- observe(nodeId, usable) {
40203
- const prev = this.state.get(nodeId);
40204
- if (usable) {
40205
- this.state.set(nodeId, {
40206
- usable: true,
40207
- armed: false
40208
- });
40209
- return prev !== void 0 && !prev.usable ? "recovered" : null;
40210
- }
40211
- if (prev === void 0) {
40212
- this.state.set(nodeId, {
40213
- usable: true,
40214
- armed: true
40215
- });
40216
- return null;
40217
- }
40218
- if (!prev.usable) {
40219
- this.state.set(nodeId, {
40220
- usable: false,
40221
- armed: true
40222
- });
40223
- return null;
40224
- }
40225
- if (!prev.armed) {
40226
- this.state.set(nodeId, {
40227
- usable: true,
40228
- armed: true
40229
- });
40230
- return null;
40231
- }
40232
- this.state.set(nodeId, {
40233
- usable: false,
40234
- armed: true
40235
- });
40236
- return "became-unusable";
40237
- }
40238
- /** Can this node be given cameras? Unknown nodes answer YES. */
40239
- isUsable(nodeId) {
40240
- return this.state.get(nodeId)?.usable ?? true;
40241
- }
40242
- /** Nodes currently excluded — for the placement log and diagnostics. */
40243
- unusableNodeIds() {
40244
- const out = [];
40245
- for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
40246
- return out.toSorted();
40247
- }
40248
- forget(nodeId) {
40249
- this.state.delete(nodeId);
40250
- }
40251
- reset() {
40252
- this.state.clear();
40253
- }
40254
- };
40255
- //#endregion
40256
- //#region src/inference-device-usability-mirror.ts
40257
- /**
40258
- * InferenceDeviceUsabilityMirror — "can this NODE's THIS DEVICE still infer?",
40259
- * kept off the placement path.
40260
- *
40261
- * The per-`(nodeId, deviceKey)` twin of {@link NodeInferenceUsabilityMirror},
40262
- * and deliberately not a second mechanism: it composes the key and delegates
40263
- * every decision to that class, so the arm/apply reluctance D49 pinned lives in
40264
- * exactly one implementation and cannot drift between the node tier and the
40265
- * device tier.
40266
- *
40267
- * ## Why this tier had to exist
40268
- *
40269
- * The node tier already answers "does this node have ANY usable accelerator".
40270
- * On 2026-08-25 the hub's answer was, correctly, yes — its NPU was healthy the
40271
- * whole time. What died was one DEVICE, `openvino:gpu`, and nothing anywhere
40272
- * asked that question: the per-dispatch capability gate is keyed on model
40273
- * FORMAT and `openvino:gpu` and `openvino:npu` share the format `openvino`, so
40274
- * it is blind between them by construction. The balancer kept rotating cameras
40275
- * onto the dead pool — 20 sessions in 20 minutes, every one `tieBreak:
40276
- * "rotation"` — for 31 hours.
40277
- *
40278
- * ## Why a mirror and not the event
40279
- *
40280
- * D49, verbatim: *a one-shot event never gates on a fallible read*. The gate is
40281
- * this in-memory mirror, refreshed off the event path by the same
40282
- * `resolveEligibleInferenceDevices` the dispatcher already runs (plus the
40283
- * session controller's background refresher). The consequences that buys:
40284
- *
40285
- * - **A read that fails changes nothing.** The caller folds in an observation
40286
- * only when it HAS one; an unreachable node, a version-skewed executor or a
40287
- * rejected RPC never reaches {@link observe}, so the previous verdict
40288
- * stands. This is the whole reason the health read is specified as
40289
- * "synchronous over in-memory state, never throws for its own reasons": an
40290
- * empty answer must mean *nothing is refused*, not *I could not tell*.
40291
- * - **Excluding a healthy device needs a second, consecutive read.** Exclusion
40292
- * is the direction that DESTROYS work — it strands an accelerator that may
40293
- * be perfectly fine — so one bad observation only ARMS.
40294
- * - **Re-admitting is immediate and unconditional.** One good observation puts
40295
- * the device straight back. Being slow to exclude costs some wasted
40296
- * inference attempts; being slow to re-admit costs an idle accelerator and a
40297
- * node that looks broken.
40298
- */
40299
- /**
40300
- * NUL — impossible in both a Moleculer node id and a `<backend>:<device>` key,
40301
- * so the composite key can never be ambiguous. A separator that CAN occur in
40302
- * either half makes two distinct pairs collide, and a collision here silently
40303
- * excludes an accelerator nobody reported.
40304
- */
40305
- var SEPARATOR = "\0";
40306
- var InferenceDeviceUsabilityMirror = class {
40307
- /** The one implementation of the arm/apply state machine (D49). */
40308
- mirror = new NodeInferenceUsabilityMirror();
40309
- /**
40310
- * Fold one observation in and report whether the caller should act.
40311
- * Never throws.
40312
- */
40313
- observe(nodeId, deviceKey, usable) {
40314
- return this.mirror.observe(`${nodeId}${SEPARATOR}${deviceKey}`, usable);
40315
- }
40316
- /** Can the balancer put a session on this device? Unknown pairs answer YES. */
40317
- isUsable(nodeId, deviceKey) {
40318
- return this.mirror.isUsable(`${nodeId}${SEPARATOR}${deviceKey}`);
40319
- }
40320
- /** Pairs currently excluded — for the placement log and diagnostics. */
40321
- unusableDevices() {
40322
- return this.mirror.unusableNodeIds().map((composite) => {
40323
- const at = composite.indexOf(SEPARATOR);
40324
- return {
40325
- nodeId: composite.slice(0, at),
40326
- deviceKey: composite.slice(at + 1)
40327
- };
40328
- });
40329
- }
40330
- /** The excluded device keys on ONE node. */
40331
- unusableDeviceKeys(nodeId) {
40332
- return this.unusableDevices().filter((d) => d.nodeId === nodeId).map((d) => d.deviceKey);
40333
- }
40334
- forget(nodeId, deviceKey) {
40335
- this.mirror.forget(`${nodeId}${SEPARATOR}${deviceKey}`);
40336
- }
40337
- reset() {
40338
- this.mirror.reset();
40339
- }
40340
- };
40341
- /**
40342
- * Fold ONE node's health answer into the mirror and return what changed.
40343
- *
40344
- * This is the whole reading discipline, in one place, because both halves of it
40345
- * are easy to get subtly wrong and neither failure is visible in a log:
40346
- *
40347
- * - **`unhealthy === null` — the read FAILED — changes nothing.** Not a single
40348
- * entry is touched. An unreachable node, a version-skewed executor or a
40349
- * rejected RPC must be distinguishable from "asked, nothing is refused", or
40350
- * a flaky link silently re-admits a dead accelerator (D49).
40351
- * - **Every device the node HAS is observed**, not merely the refused ones.
40352
- * The first draft observed `refused ∪ already-excluded`, which omits exactly
40353
- * the devices the mirror has ARMED — so their disarming good read never
40354
- * arrived and two bad reads an HOUR apart, with a hundred healthy ones
40355
- * between them, excluded a working accelerator. "Consecutive" is only a
40356
- * property if the good observations are delivered.
40357
- *
40358
- * Pure with respect to everything except `mirror`, and never throws — it is
40359
- * called from the dispatcher's own read path.
40360
- */
40361
- function foldInferenceDeviceHealth(mirror, nodeId, unhealthy, present) {
40362
- if (unhealthy === null) return [];
40363
- const refused = new Set(unhealthy);
40364
- const observed = new Set([
40365
- ...present,
40366
- ...refused,
40367
- ...mirror.unusableDeviceKeys(nodeId)
40368
- ]);
40369
- const changes = [];
40370
- for (const deviceKey of observed) {
40371
- const transition = mirror.observe(nodeId, deviceKey, !refused.has(deviceKey));
40372
- if (transition !== null) changes.push({
40373
- deviceKey,
40374
- transition
40375
- });
40376
- }
40377
- return changes;
40378
- }
40379
- //#endregion
40380
- //#region src/orchestrator-types.ts
40381
- var PHASE_MODE_VALUES = new Set([
40382
- "disabled",
40383
- "always-on",
40384
- "on-motion"
40385
- ]);
40386
- function isPipelinePhaseMode(v) {
40387
- return PHASE_MODE_VALUES.has(v);
40388
- }
40389
- /**
40390
- * Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
40391
- * that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
40392
- * `reconcileDispatch` is additive-only and never revisits these, so a slow
40393
- * safety-net timer + event-driven debounce triggers recover them.
40394
- */
40395
- var PENDING_RETRY_INTERVAL_MS = 6e4;
40396
- /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
40397
- var PENDING_RETRY_DEBOUNCE_MS = 2e3;
40398
- /**
40399
- * Periodic auto-rebalance sweep. New attaches are already load-balanced at
40400
- * dispatch time; this corrects DRIFT that accumulates over time (uneven
40401
- * detach, a node returning online, a weight change) so the steady-state
40402
- * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
40403
- * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
40404
- */
40405
- var AUTO_REBALANCE_INTERVAL_MS = 6e4;
40406
- /**
40407
- * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
40408
- * migrate a camera only when its target node is at least this much less loaded
40409
- * than its current node. > 1 so equalizing a single-camera gap (which would
40410
- * only reverse the imbalance) is skipped — prevents periodic churn.
40411
- */
40412
- var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
40413
- var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
40414
- /**
40415
- * Device-details keys routed through the orchestrator's pipeline
40416
- * settings writer instead of the device orchestration store. The
40417
- * `cameraPipeline` key carries the full `CameraPipelineConfig`
40418
- * emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
40419
- */
40420
- var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
40421
- var DEFAULT_FAILOVER_POLICY = {
40422
- onDisconnect: "migrate",
40423
- pinnedOnDisconnect: "leave-pinned",
40424
- onReconnect: "restore"
40425
- };
40426
- /**
40427
- * Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
40428
- *
40429
- * The orchestrator's cap surface is the contract for all runtime traffic
40430
- * (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
40431
- * catalog is reserved for read-only diagnostics that are intentionally
40432
- * outside the cap — they expose internal state (balancer caches, enabledNodes
40433
- * set, active detection count) that is useful for admin tooling but does not
40434
- * belong on the capability contract.
40435
- */
40436
- var OrchestratorDiagnosticsSchema = object({
40437
- localNodeId: string(),
40438
- knownRunnerNodes: array(string()),
40439
- cachedAgentLoadNodeIds: array(string()),
40440
- enabledNodes: array(string()),
40441
- enabledDecoderNodes: array(string()),
40442
- enabledAudioNodes: array(string()),
40443
- enabledIngestNodes: array(string()),
40444
- clusterRoles: object({
40445
- ingestNode: string(),
40446
- audioNode: string(),
40447
- motionNode: string()
40448
- }),
40449
- assignedDeviceCount: number().int().min(0),
40450
- cameraConfigCount: number().int().min(0),
40451
- activeDetectionCount: number().int().min(0)
40452
- });
40453
- /**
40454
- * The node-stress long-term-statistics read surface.
40455
- *
40456
- * A custom action rather than a cap method, matching how the orchestrator
40457
- * already serves `dumpState`: this is a hub-local read over a table the hub
40458
- * owns, and it ships with one `camstack deploy` instead of a release train.
40459
- * The MEAN is derived here and returned alongside the addable `sum`/`samples`
40460
- * — a chart wants the first, a re-bucketing caller wants the second, and a
40461
- * stored mean is a field that can disagree with both.
40462
- */
40463
- var NodeStressStatsInputSchema = object({
40464
- /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
40465
- series: string().optional(),
40466
- /** A node id. Omit for every node. */
40467
- subject: string().optional(),
40468
- /** Inclusive bucket-start bounds, ms. */
40469
- from: number().int().optional(),
40470
- to: number().int().optional(),
40471
- limit: number().int().positive().max(5e3).optional()
40472
- });
40473
- var NodeStressStatsRowSchema = object({
40474
- subject: string(),
40475
- series: string(),
40476
- scope: string(),
40477
- bucketStart: number(),
40478
- samples: number(),
40479
- sum: number(),
40480
- mean: number(),
40481
- min: number(),
40482
- max: number()
40483
- });
40484
- var NodeStressStatsOutputSchema = object({
40485
- rows: array(NodeStressStatsRowSchema).readonly(),
40486
- /** Buckets still accumulating — "is it running" answerable at once, rather
40487
- * than after five minutes of indistinguishable silence. */
40488
- open: array(NodeStressStatsRowSchema).readonly(),
40489
- /** The durable failover history the anti-flap guards read, newest first.
40490
- * Exposed for the same reason the heartbeat exists: "nothing moved" has to
40491
- * be distinguishable from "nothing is watching". */
40492
- moves: array(object({
40493
- deviceId: number(),
40494
- fromNodeId: string(),
40495
- at: number()
40496
- })).readonly()
40497
- });
40498
- var pipelineOrchestratorActions = defineCustomActions({
40499
- dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
40500
- nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
40501
- });
40502
- /**
40503
- * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
40504
- * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
40505
- * while the stream-broker is (re)starting) — as opposed to `null`, which
40506
- * means "genuinely no assigned slot / not configured". Callers MUST treat
40507
- * this differently from `null`: never stop active detection on a transient
40508
- * read failure (the slots almost certainly still exist), and schedule a retry.
40509
- */
40510
- var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
40511
- //#endregion
40512
- //#region src/audio-chunk-poller.ts
40513
- /**
40514
- * `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
40515
- * plane (Phase 5 / D9).
40516
- *
40517
- * Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
40518
- * path. A live callback cannot cross a process boundary; once the `pipeline`
40519
- * group is dissolved (Task 8) the orchestrator runs in a different process
40520
- * from the broker, so audio delivery must go over tRPC.
40521
- *
40522
- * The consumer:
40523
- *
40524
- * 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
40525
- * registers a per-subscription bounded FIFO queue and returns a
40526
- * `subscriptionId`;
40527
- * 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
40528
- * interval, draining `DecodedAudioChunk`s in FIFO arrival order;
40529
- * 3. feeds each chunk to its downstream audio logic;
40530
- * 4. on teardown, `unsubscribeAudioChunks`.
40531
- *
40532
- * Audio is not latency-critical like video, and chunks arrive only ~every
40533
- * 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
40534
- * a small per-poll burst keeps latency low without busy-spinning. The
40535
- * broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
40536
- * loses a chunk.
40537
- *
40538
- * Boot-race tolerance: the broker for a given camStream may not be registered
40539
- * yet when the orchestrator wires the subscription (provider addons publish
40540
- * their cameraStreams asynchronously after their probe completes).
40541
- * `subscribeAudioChunks` retries with exponential backoff (capped at
40542
- * `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
40543
- * teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
40544
- * shape so video and audio plumbing self-heal identically.
40545
- */
40546
- /** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
40547
- var POLL_INTERVAL_MS$1 = 200;
40548
- /** How many chunks to drain per poll — a small burst absorbs jitter. */
40549
- var PULL_MAX_COUNT = 8;
40550
- /**
40551
- * Consecutive pull failures before we attempt to re-subscribe. A single failed
40552
- * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
40553
- * sustained failure means the broker child restarted and dropped our
40554
- * subscription, so we re-establish it.
40555
- */
40556
- var RESUBSCRIBE_AFTER_FAILURES = 2;
40557
- /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
40558
- var RESUBSCRIBE_THROTTLE_TICKS = 5;
40559
- /** First subscribe-retry delay, doubled on every subsequent failure. */
40560
- var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
40561
- /**
40562
- * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
40563
- * enough to recover within a single reconcile of the orchestrator and slow
40564
- * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
40565
- */
40566
- var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
40567
- /**
40568
- * Attempts after which a still-failing subscribe escalates from the fast 5 s
40569
- * ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
40570
- * minute of fast retries — plenty for the boot races the 5 s ceiling exists
40571
- * for. A broker that is STILL absent after that is a long-lived condition
40572
- * (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
40573
- * to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
40574
- * churn. The slow loop stays alive so audio still recovers automatically
40575
- * (≤60 s) once the camera is re-enabled.
40576
- */
40577
- var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
40578
- var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
40579
- /**
40580
- * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
40581
- *
40582
- * Always resolves to a teardown closure — when the broker is not yet
40583
- * registered the closure cancels the ongoing retry loop; when polling is
40584
- * active it stops the loop and releases the broker subscription. Mirrors
40585
- * `startFrameHandlePoller` so video and audio recover identically.
40586
- */
40587
- function startAudioChunkPoller(options) {
40588
- const lifecycle = {
40589
- stopped: false,
40590
- retryTimer: void 0,
40591
- pollTimer: void 0,
40592
- activeSubscriptionId: null
40593
- };
40594
- const teardown = () => {
40595
- if (lifecycle.stopped) return;
40596
- lifecycle.stopped = true;
40597
- if (lifecycle.retryTimer) {
40598
- clearTimeout(lifecycle.retryTimer);
40599
- lifecycle.retryTimer = void 0;
40600
- }
40601
- if (lifecycle.pollTimer) {
40602
- clearTimeout(lifecycle.pollTimer);
40603
- lifecycle.pollTimer = void 0;
40604
- }
40605
- const subId = lifecycle.activeSubscriptionId;
40606
- if (subId) {
40607
- lifecycle.activeSubscriptionId = null;
40608
- options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
40609
- options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
40610
- brokerId: options.brokerId,
40611
- subscriptionId: subId,
40612
- error: errMsg(err)
40613
- } });
40614
- });
40615
- }
40616
- };
40617
- subscribeWithRetry(options, lifecycle);
40618
- return teardown;
40217
+ subscribeWithRetry(options, lifecycle);
40218
+ return teardown;
40619
40219
  }
40620
40220
  /**
40621
40221
  * Run the subscribe → poll handshake with exponential backoff on subscribe
@@ -40767,47 +40367,179 @@ function balanceAudio(input) {
40767
40367
  };
40768
40368
  }
40769
40369
  //#endregion
40770
- //#region src/audio-window-accumulator.ts
40771
- var AudioWindowAccumulator = class {
40772
- deviceId;
40773
- pcmParts = [];
40774
- accumulatedBytes = 0;
40775
- accumulatedMs = 0;
40776
- windowSampleRate = 0;
40777
- windowChannels = 0;
40778
- windowTimestamp = 0;
40779
- windowOpen = false;
40780
- constructor(deviceId) {
40781
- this.deviceId = deviceId;
40782
- }
40783
- /**
40784
- * Append one decoded PCM chunk to the open window. Returns the flushed
40785
- * `AudioChunkInput` once the accumulated duration reaches
40786
- * `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
40787
- * (accumulate-only, no flush yet).
40788
- */
40789
- push(chunk) {
40790
- const byteLength = chunk.data.byteLength;
40791
- const bytes = new Uint8Array(byteLength);
40792
- bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
40793
- if (!this.windowOpen) {
40794
- this.windowSampleRate = chunk.sampleRate;
40795
- this.windowChannels = chunk.channels;
40796
- this.windowTimestamp = chunk.timestamp;
40797
- this.windowOpen = true;
40798
- }
40799
- this.pcmParts.push(bytes);
40800
- this.accumulatedBytes += byteLength;
40801
- const channels = chunk.channels > 0 ? chunk.channels : 1;
40802
- const framesPerChannel = byteLength / 4 / channels;
40803
- this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
40804
- if (this.accumulatedMs < 1e3) return null;
40805
- const windowData = new Uint8Array(this.accumulatedBytes);
40806
- let offset = 0;
40807
- for (const part of this.pcmParts) {
40808
- windowData.set(part, offset);
40809
- offset += part.byteLength;
40810
- }
40370
+ //#region src/orchestrator-types.ts
40371
+ var PHASE_MODE_VALUES = new Set([
40372
+ "disabled",
40373
+ "always-on",
40374
+ "on-motion"
40375
+ ]);
40376
+ function isPipelinePhaseMode(v) {
40377
+ return PHASE_MODE_VALUES.has(v);
40378
+ }
40379
+ /**
40380
+ * Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
40381
+ * that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
40382
+ * `reconcileDispatch` is additive-only and never revisits these, so a slow
40383
+ * safety-net timer + event-driven debounce triggers recover them.
40384
+ */
40385
+ var PENDING_RETRY_INTERVAL_MS = 6e4;
40386
+ /** Debounce window for `schedulePendingRetry` coalesces capacity/eligibility/readiness signals. */
40387
+ var PENDING_RETRY_DEBOUNCE_MS = 2e3;
40388
+ /**
40389
+ * Periodic auto-rebalance sweep. New attaches are already load-balanced at
40390
+ * dispatch time; this corrects DRIFT that accumulates over time (uneven
40391
+ * detach, a node returning online, a weight change) so the steady-state
40392
+ * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
40393
+ * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
40394
+ */
40395
+ var AUTO_REBALANCE_INTERVAL_MS = 6e4;
40396
+ /**
40397
+ * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
40398
+ * migrate a camera only when its target node is at least this much less loaded
40399
+ * than its current node. > 1 so equalizing a single-camera gap (which would
40400
+ * only reverse the imbalance) is skipped — prevents periodic churn.
40401
+ */
40402
+ var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
40403
+ var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
40404
+ /**
40405
+ * Device-details keys routed through the orchestrator's pipeline
40406
+ * settings writer instead of the device orchestration store. The
40407
+ * `cameraPipeline` key carries the full `CameraPipelineConfig`
40408
+ * emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
40409
+ */
40410
+ var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
40411
+ var DEFAULT_FAILOVER_POLICY = {
40412
+ onDisconnect: "migrate",
40413
+ pinnedOnDisconnect: "leave-pinned",
40414
+ onReconnect: "restore"
40415
+ };
40416
+ /**
40417
+ * Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
40418
+ *
40419
+ * The orchestrator's cap surface is the contract for all runtime traffic
40420
+ * (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
40421
+ * catalog is reserved for read-only diagnostics that are intentionally
40422
+ * outside the cap — they expose internal state (balancer caches, enabledNodes
40423
+ * set, active detection count) that is useful for admin tooling but does not
40424
+ * belong on the capability contract.
40425
+ */
40426
+ var OrchestratorDiagnosticsSchema = object({
40427
+ localNodeId: string(),
40428
+ knownRunnerNodes: array(string()),
40429
+ cachedAgentLoadNodeIds: array(string()),
40430
+ enabledNodes: array(string()),
40431
+ enabledDecoderNodes: array(string()),
40432
+ enabledAudioNodes: array(string()),
40433
+ enabledIngestNodes: array(string()),
40434
+ clusterRoles: object({
40435
+ ingestNode: string(),
40436
+ audioNode: string(),
40437
+ motionNode: string()
40438
+ }),
40439
+ assignedDeviceCount: number().int().min(0),
40440
+ cameraConfigCount: number().int().min(0),
40441
+ activeDetectionCount: number().int().min(0)
40442
+ });
40443
+ /**
40444
+ * The node-stress long-term-statistics read surface.
40445
+ *
40446
+ * A custom action rather than a cap method, matching how the orchestrator
40447
+ * already serves `dumpState`: this is a hub-local read over a table the hub
40448
+ * owns, and it ships with one `camstack deploy` instead of a release train.
40449
+ * The MEAN is derived here and returned alongside the addable `sum`/`samples`
40450
+ * — a chart wants the first, a re-bucketing caller wants the second, and a
40451
+ * stored mean is a field that can disagree with both.
40452
+ */
40453
+ var NodeStressStatsInputSchema = object({
40454
+ /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
40455
+ series: string().optional(),
40456
+ /** A node id. Omit for every node. */
40457
+ subject: string().optional(),
40458
+ /** Inclusive bucket-start bounds, ms. */
40459
+ from: number().int().optional(),
40460
+ to: number().int().optional(),
40461
+ limit: number().int().positive().max(5e3).optional()
40462
+ });
40463
+ var NodeStressStatsRowSchema = object({
40464
+ subject: string(),
40465
+ series: string(),
40466
+ scope: string(),
40467
+ bucketStart: number(),
40468
+ samples: number(),
40469
+ sum: number(),
40470
+ mean: number(),
40471
+ min: number(),
40472
+ max: number()
40473
+ });
40474
+ var NodeStressStatsOutputSchema = object({
40475
+ rows: array(NodeStressStatsRowSchema).readonly(),
40476
+ /** Buckets still accumulating — "is it running" answerable at once, rather
40477
+ * than after five minutes of indistinguishable silence. */
40478
+ open: array(NodeStressStatsRowSchema).readonly(),
40479
+ /** The durable failover history the anti-flap guards read, newest first.
40480
+ * Exposed for the same reason the heartbeat exists: "nothing moved" has to
40481
+ * be distinguishable from "nothing is watching". */
40482
+ moves: array(object({
40483
+ deviceId: number(),
40484
+ fromNodeId: string(),
40485
+ at: number()
40486
+ })).readonly()
40487
+ });
40488
+ var pipelineOrchestratorActions = defineCustomActions({
40489
+ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
40490
+ nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
40491
+ });
40492
+ /**
40493
+ * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
40494
+ * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
40495
+ * while the stream-broker is (re)starting) — as opposed to `null`, which
40496
+ * means "genuinely no assigned slot / not configured". Callers MUST treat
40497
+ * this differently from `null`: never stop active detection on a transient
40498
+ * read failure (the slots almost certainly still exist), and schedule a retry.
40499
+ */
40500
+ var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
40501
+ //#endregion
40502
+ //#region src/audio-window-accumulator.ts
40503
+ var AudioWindowAccumulator = class {
40504
+ deviceId;
40505
+ pcmParts = [];
40506
+ accumulatedBytes = 0;
40507
+ accumulatedMs = 0;
40508
+ windowSampleRate = 0;
40509
+ windowChannels = 0;
40510
+ windowTimestamp = 0;
40511
+ windowOpen = false;
40512
+ constructor(deviceId) {
40513
+ this.deviceId = deviceId;
40514
+ }
40515
+ /**
40516
+ * Append one decoded PCM chunk to the open window. Returns the flushed
40517
+ * `AudioChunkInput` once the accumulated duration reaches
40518
+ * `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
40519
+ * (accumulate-only, no flush yet).
40520
+ */
40521
+ push(chunk) {
40522
+ const byteLength = chunk.data.byteLength;
40523
+ const bytes = new Uint8Array(byteLength);
40524
+ bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
40525
+ if (!this.windowOpen) {
40526
+ this.windowSampleRate = chunk.sampleRate;
40527
+ this.windowChannels = chunk.channels;
40528
+ this.windowTimestamp = chunk.timestamp;
40529
+ this.windowOpen = true;
40530
+ }
40531
+ this.pcmParts.push(bytes);
40532
+ this.accumulatedBytes += byteLength;
40533
+ const channels = chunk.channels > 0 ? chunk.channels : 1;
40534
+ const framesPerChannel = byteLength / 4 / channels;
40535
+ this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
40536
+ if (this.accumulatedMs < 1e3) return null;
40537
+ const windowData = new Uint8Array(this.accumulatedBytes);
40538
+ let offset = 0;
40539
+ for (const part of this.pcmParts) {
40540
+ windowData.set(part, offset);
40541
+ offset += part.byteLength;
40542
+ }
40811
40543
  const flushSampleRate = this.windowSampleRate;
40812
40544
  const flushChannels = this.windowChannels;
40813
40545
  const flushTimestamp = this.windowTimestamp;
@@ -41270,234 +41002,847 @@ var AudioSubscriptionController = class {
41270
41002
  meta: { error: errMsg(err) }
41271
41003
  });
41272
41004
  });
41273
- }, windowMs);
41274
- this.motionAudioWindowTimers.set(deviceId, timer);
41275
- }
41276
- /** True while a motion-driven audio window is open for this device. */
41277
- isMotionAudioWindowOpen(deviceId) {
41278
- return this.motionAudioWindowTimers.has(deviceId);
41279
- }
41280
- /**
41281
- * Close an on-motion audio window and drop its subscription. Shared by both
41282
- * closers (quiet window elapsed / falling edge cooldown) so they can never
41283
- * disagree about what "closed" means. `reason` is logged so a camera that
41284
- * loses audio can always be told WHY from the per-device log view.
41285
- */
41286
- async closeMotionAudioWindow(deviceId, reason) {
41287
- const pendingWindow = this.motionAudioWindowTimers.get(deviceId);
41288
- if (pendingWindow) {
41289
- clearTimeout(pendingWindow);
41290
- this.motionAudioWindowTimers.delete(deviceId);
41005
+ }, windowMs);
41006
+ this.motionAudioWindowTimers.set(deviceId, timer);
41007
+ }
41008
+ /** True while a motion-driven audio window is open for this device. */
41009
+ isMotionAudioWindowOpen(deviceId) {
41010
+ return this.motionAudioWindowTimers.has(deviceId);
41011
+ }
41012
+ /**
41013
+ * Close an on-motion audio window and drop its subscription. Shared by both
41014
+ * closers (quiet window elapsed / falling edge cooldown) so they can never
41015
+ * disagree about what "closed" means. `reason` is logged so a camera that
41016
+ * loses audio can always be told WHY from the per-device log view.
41017
+ */
41018
+ async closeMotionAudioWindow(deviceId, reason) {
41019
+ const pendingWindow = this.motionAudioWindowTimers.get(deviceId);
41020
+ if (pendingWindow) {
41021
+ clearTimeout(pendingWindow);
41022
+ this.motionAudioWindowTimers.delete(deviceId);
41023
+ }
41024
+ await this.withAudioSubLock(deviceId, async () => {
41025
+ const unsub = this.audioSubscriptions.get(deviceId);
41026
+ if (!unsub) return;
41027
+ try {
41028
+ unsub();
41029
+ } catch {}
41030
+ this.audioSubscriptions.delete(deviceId);
41031
+ this.deps.logger.info("lazy audio: window closed", {
41032
+ tags: { deviceId },
41033
+ meta: { reason }
41034
+ });
41035
+ });
41036
+ }
41037
+ /**
41038
+ * Audio teardown for one device — the audio half of `stopDetection`.
41039
+ * Tears down through the per-device lock so it can't race a concurrent
41040
+ * subscribe (which would re-store a handle this teardown never sees).
41041
+ */
41042
+ async stopForDevice(deviceId) {
41043
+ await this.withAudioSubLock(deviceId, async () => {
41044
+ const unsub = this.audioSubscriptions.get(deviceId);
41045
+ if (unsub) {
41046
+ try {
41047
+ unsub();
41048
+ } catch {}
41049
+ this.audioSubscriptions.delete(deviceId);
41050
+ }
41051
+ });
41052
+ const lazyTimer = this.lazyAudioTeardownTimers.get(deviceId);
41053
+ if (lazyTimer) {
41054
+ clearTimeout(lazyTimer);
41055
+ this.lazyAudioTeardownTimers.delete(deviceId);
41056
+ }
41057
+ const windowTimer = this.motionAudioWindowTimers.get(deviceId);
41058
+ if (windowTimer) {
41059
+ clearTimeout(windowTimer);
41060
+ this.motionAudioWindowTimers.delete(deviceId);
41061
+ }
41062
+ this.audioAssignments.delete(deviceId);
41063
+ }
41064
+ /**
41065
+ * Centralized write into `audioSubscriptions`. If shutdown has begun, the
41066
+ * map has already been (or is about to be) cleared lock-free in
41067
+ * `shutdown()`; storing here would leak a zombie entry whose `unsub` is
41068
+ * never called. So when shutting down we immediately invoke `unsub`
41069
+ * (best-effort, error-swallowed) and DO NOT store. `protected` so
41070
+ * `audio-sub-lock.spec.ts`'s test subclass can assert the shutdown-guard
41071
+ * behavior without casts.
41072
+ */
41073
+ storeAudioSub(deviceId, unsub) {
41074
+ if (this.audioShuttingDown) {
41075
+ try {
41076
+ unsub();
41077
+ } catch {}
41078
+ return;
41079
+ }
41080
+ this.audioSubscriptions.set(deviceId, unsub);
41081
+ }
41082
+ /**
41083
+ * Serialize an audio-subscription critical section per device. `fn` is
41084
+ * chained onto the device's current lock tail, so concurrent calls for the
41085
+ * SAME deviceId run sequentially (FIFO); different deviceIds never block
41086
+ * each other. Thin delegate onto the `audioSubLocks` `KeyedAsyncLock`
41087
+ * instance. `protected` so `audio-sub-lock.spec.ts`'s test subclass can
41088
+ * drive the lock without casts.
41089
+ */
41090
+ withAudioSubLock(deviceId, fn) {
41091
+ return this.audioSubLocks.run(deviceId, fn);
41092
+ }
41093
+ /**
41094
+ * Subscribe to decoded audio chunks for a camera and feed them into the
41095
+ * audio-analyzer. Reads the analyzer's settings via its own
41096
+ * `resolveDeviceSettings(deviceId)` method so the orchestrator does not
41097
+ * touch the audio-analyzer schema field names directly.
41098
+ */
41099
+ async subscribeAudioStream(deviceId, config) {
41100
+ const api = this.deps.api();
41101
+ if (!api) {
41102
+ this.deps.logger.warn("this.ctx.api not available — cannot subscribe audio", { tags: { deviceId } });
41103
+ return null;
41104
+ }
41105
+ if (!await this.deps.isAudioAnalysisActive(deviceId)) return null;
41106
+ if (config.audioMode === "disabled") {
41107
+ this.deps.logger.debug("audio subscribe skipped: audioMode=disabled", { tags: { deviceId } });
41108
+ return null;
41109
+ }
41110
+ if (config.audioMode === "on-motion" && !this.isMotionAudioWindowOpen(deviceId)) {
41111
+ this.deps.logger.info("audio subscribe deferred: audioMode=on-motion, no window open", { tags: { deviceId } });
41112
+ return null;
41113
+ }
41114
+ const audioStream = config.audioStreamId ?? config.motionStreamId;
41115
+ const audioBrokerId = makeSourceBrokerId(deviceId, audioStream);
41116
+ if ((await this.deps.probeAudioTrack(deviceId, audioStream)).kind === "absent") {
41117
+ this.deps.logger.warn("audio subscription REFUSED — this stream carries no audio track", {
41118
+ tags: { deviceId },
41119
+ meta: {
41120
+ camStreamId: audioStream,
41121
+ brokerId: audioBrokerId,
41122
+ selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
41123
+ hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
41124
+ }
41125
+ });
41126
+ return null;
41127
+ }
41128
+ const settings = await api.audioAnalysis.resolveDeviceSettings.query({ deviceId });
41129
+ if (!settings) {
41130
+ this.deps.logger.warn("audio-analysis returned no settings — audio subscription skipped", { tags: { deviceId } });
41131
+ return null;
41132
+ }
41133
+ const audioNodeId = await this.dispatch(deviceId);
41134
+ const isRemoteAudio = audioNodeId !== this.deps.localNodeId();
41135
+ this.deps.logger.info("audio subscription: resolved audio node", {
41136
+ tags: { deviceId },
41137
+ meta: {
41138
+ audioNodeId,
41139
+ isRemote: isRemoteAudio
41140
+ }
41141
+ });
41142
+ const accumulator = new AudioWindowAccumulator(deviceId);
41143
+ const teardown = startAudioChunkPoller({
41144
+ api,
41145
+ brokerId: audioBrokerId,
41146
+ tag: "audio-analyzer",
41147
+ ownerNodeId: this.deps.ingestNode(),
41148
+ logger: this.deps.logger.withTags({ deviceId }),
41149
+ onChunk: async (chunk) => {
41150
+ this.deps.watchdogNote(deviceId, "audio");
41151
+ try {
41152
+ const audioChunkInput = accumulator.push(chunk);
41153
+ if (!audioChunkInput) return;
41154
+ const result = await api.audioAnalyzer.analyseChunk.mutate({
41155
+ chunk: audioChunkInput,
41156
+ settings,
41157
+ ...isRemoteAudio ? { nodeId: audioNodeId } : {}
41158
+ });
41159
+ if (!result) return;
41160
+ const frame = buildAudioResultFrame(deviceId, result);
41161
+ this.deps.eventBus.emit({
41162
+ id: `audio-inference-${deviceId}-${Date.now()}`,
41163
+ timestamp: /* @__PURE__ */ new Date(),
41164
+ source: {
41165
+ type: "device",
41166
+ id: deviceId,
41167
+ nodeId: "hub",
41168
+ addonId: "pipeline-orchestrator",
41169
+ deviceId
41170
+ },
41171
+ category: EventCategory.PipelineAudioInferenceResult,
41172
+ data: {
41173
+ deviceId,
41174
+ frame,
41175
+ nodeId: "hub"
41176
+ }
41177
+ });
41178
+ } catch (err) {
41179
+ const msg = errMsg(err);
41180
+ this.deps.logger.error("Audio analysis failed", {
41181
+ tags: { deviceId },
41182
+ meta: { error: msg }
41183
+ });
41184
+ }
41185
+ }
41186
+ });
41187
+ this.deps.logger.info("Audio stream subscribed", { tags: { deviceId } });
41188
+ return () => {
41189
+ teardown();
41190
+ accumulator.reset();
41191
+ };
41192
+ }
41193
+ /**
41194
+ * Set true at the very start of `onShutdown`, before the audio teardown /
41195
+ * map clears below. Once set, `withAudioSubLock` turns queued/new critical
41196
+ * sections into no-ops and `storeAudioSub` refuses to store, so no
41197
+ * critical section that was in-flight (or queued) when shutdown began can
41198
+ * resurrect a zombie subscription into the cleared `audioSubscriptions`
41199
+ * map. MUST be called before anything else in `onShutdown` that could
41200
+ * race a queued audio critical section (mirrors the original
41201
+ * `this.audioShuttingDown = true` being the very first statement).
41202
+ */
41203
+ beginShutdown() {
41204
+ this.audioShuttingDown = true;
41205
+ }
41206
+ /**
41207
+ * Full audio teardown — combines the former `onShutdown`'s two separate
41208
+ * audio blocks (lazy-teardown-timer clear, then — after several unrelated
41209
+ * session/reconcile/load-shed clears — subscription teardown + lock clear
41210
+ * + assignment-map clears) into one call. Safe to combine: both blocks
41211
+ * are synchronous with no interleaved `await`, and every original
41212
+ * statement between them (`sessionRegistry.clear()`,
41213
+ * `cameraFpsMap.clear()`, `remoteHealthAttempts.clear()`,
41214
+ * `loadShedState.clear()`, `loadShedResumeTimer` cleanup) touches state
41215
+ * fully disjoint from anything audio — so their relative order to each
41216
+ * other is unaffected, and the audio-internal order (timers →
41217
+ * subscriptions → lock → assignment maps) is reproduced exactly.
41218
+ */
41219
+ shutdown() {
41220
+ for (const t of this.lazyAudioTeardownTimers.values()) clearTimeout(t);
41221
+ this.lazyAudioTeardownTimers.clear();
41222
+ for (const t of this.motionAudioWindowTimers.values()) clearTimeout(t);
41223
+ this.motionAudioWindowTimers.clear();
41224
+ for (const unsub of this.audioSubscriptions.values()) try {
41225
+ unsub();
41226
+ } catch {}
41227
+ this.audioSubscriptions.clear();
41228
+ this.audioSubLocks.clear();
41229
+ this.audioAssignments.clear();
41230
+ this.readyAudioNodes.clear();
41231
+ }
41232
+ };
41233
+ //#endregion
41234
+ //#region src/disk-reconcile-fleet.ts
41235
+ async function reconcileFleetFromDisk(deps) {
41236
+ const deviceIds = await deps.listDeviceIds();
41237
+ const failed = [];
41238
+ let cameras = 0;
41239
+ let mediaDropped = 0;
41240
+ let tracks = 0;
41241
+ let events = 0;
41242
+ let completed = 0;
41243
+ for (const deviceId of deviceIds) {
41244
+ try {
41245
+ await deps.rescanRecordings(deviceId);
41246
+ const counts = await deps.reconcileAnalytics(deviceId);
41247
+ cameras += 1;
41248
+ mediaDropped += counts.mediaDropped;
41249
+ tracks += counts.tracks;
41250
+ events += counts.events;
41251
+ } catch {
41252
+ failed.push(deviceId);
41253
+ }
41254
+ completed += 1;
41255
+ deps.onProgress?.({
41256
+ deviceId,
41257
+ total: deviceIds.length,
41258
+ completed,
41259
+ failed: [...failed],
41260
+ mediaDropped,
41261
+ tracks,
41262
+ events
41263
+ });
41264
+ }
41265
+ return {
41266
+ cameras,
41267
+ failed,
41268
+ mediaDropped,
41269
+ tracks,
41270
+ events
41271
+ };
41272
+ }
41273
+ //#endregion
41274
+ //#region src/disk-reconcile-job.ts
41275
+ /**
41276
+ * In-memory disk-wins fleet job. The tRPC mutation starts this and returns
41277
+ * immediately; the walk runs in the addon process so a 60s UDS timeout cannot
41278
+ * abort it. Status is polled via getReconcileFromDiskStatus.
41279
+ */
41280
+ function idleDiskReconcileJob() {
41281
+ return {
41282
+ state: "idle",
41283
+ total: 0,
41284
+ completed: 0,
41285
+ currentDeviceId: null,
41286
+ failed: [],
41287
+ mediaDropped: 0,
41288
+ tracks: 0,
41289
+ events: 0,
41290
+ startedAtMs: null,
41291
+ finishedAtMs: null,
41292
+ error: null
41293
+ };
41294
+ }
41295
+ function isTimeoutError(err) {
41296
+ const message = err instanceof Error ? err.message : String(err);
41297
+ return /timed out/i.test(message);
41298
+ }
41299
+ async function withTimeoutRetry(run) {
41300
+ try {
41301
+ return await run();
41302
+ } catch (err) {
41303
+ if (!isTimeoutError(err)) throw err;
41304
+ return await run();
41305
+ }
41306
+ }
41307
+ function createDiskReconcileJobRunner(now = Date.now) {
41308
+ let job = idleDiskReconcileJob();
41309
+ let inFlight = null;
41310
+ const snapshot = () => job;
41311
+ const start = (deps) => {
41312
+ if (job.state === "running" && inFlight) return job;
41313
+ job = {
41314
+ ...idleDiskReconcileJob(),
41315
+ state: "running",
41316
+ startedAtMs: now()
41317
+ };
41318
+ deps.log?.("pipeline disk reconcile started");
41319
+ inFlight = (async () => {
41320
+ try {
41321
+ const result = await reconcileFleetFromDisk({
41322
+ listDeviceIds: deps.listDeviceIds,
41323
+ rescanRecordings: (deviceId) => withTimeoutRetry(() => deps.rescanRecordings(deviceId)),
41324
+ reconcileAnalytics: (deviceId) => withTimeoutRetry(() => deps.reconcileAnalytics(deviceId)),
41325
+ onProgress: (update) => {
41326
+ job = {
41327
+ ...job,
41328
+ total: update.total,
41329
+ completed: update.completed,
41330
+ currentDeviceId: update.deviceId,
41331
+ failed: update.failed,
41332
+ mediaDropped: update.mediaDropped,
41333
+ tracks: update.tracks,
41334
+ events: update.events
41335
+ };
41336
+ deps.onProgress?.(update);
41337
+ deps.log?.("pipeline disk reconcile camera", {
41338
+ deviceId: update.deviceId,
41339
+ completed: update.completed,
41340
+ total: update.total,
41341
+ failed: update.failed.length,
41342
+ mediaDropped: update.mediaDropped,
41343
+ tracks: update.tracks,
41344
+ events: update.events
41345
+ });
41346
+ }
41347
+ });
41348
+ job = {
41349
+ ...job,
41350
+ state: "done",
41351
+ total: result.cameras + result.failed.length,
41352
+ completed: result.cameras + result.failed.length,
41353
+ currentDeviceId: null,
41354
+ failed: result.failed,
41355
+ mediaDropped: result.mediaDropped,
41356
+ tracks: result.tracks,
41357
+ events: result.events,
41358
+ finishedAtMs: now(),
41359
+ error: null
41360
+ };
41361
+ deps.log?.("pipeline disk reconcile", {
41362
+ cameras: result.cameras,
41363
+ failed: result.failed,
41364
+ mediaDropped: result.mediaDropped,
41365
+ tracks: result.tracks,
41366
+ events: result.events
41367
+ });
41368
+ } catch (err) {
41369
+ const error = err instanceof Error ? err.message : String(err);
41370
+ job = {
41371
+ ...job,
41372
+ state: "error",
41373
+ currentDeviceId: null,
41374
+ finishedAtMs: now(),
41375
+ error
41376
+ };
41377
+ deps.log?.("pipeline disk reconcile failed", { error });
41378
+ } finally {
41379
+ inFlight = null;
41380
+ }
41381
+ })();
41382
+ return job;
41383
+ };
41384
+ return {
41385
+ snapshot,
41386
+ start
41387
+ };
41388
+ }
41389
+ //#endregion
41390
+ //#region src/inference-device-model.ts
41391
+ /**
41392
+ * Per-device default object-detection model + deviceKey parsing for the
41393
+ * orchestrator's device-aware `getNodeInferenceDevices` view.
41394
+ *
41395
+ * This DUPLICATES the executor's per-device model resolution (P0-3:
41396
+ * `resolveDeviceEngine` + `MODEL_BY_CLASS` + the object-detection step's
41397
+ * `defaultModelIdByFormat` in `@camstack/addon-pipeline`). It is duplicated —
41398
+ * not imported — because cross-addon imports are forbidden (the orchestrator
41399
+ * and the detection-pipeline are separate addons; only tRPC crosses the
41400
+ * boundary). Keep this in sync with `default-detection-model.ts` /
41401
+ * `step-definitions.ts` if the executor's defaults change.
41402
+ *
41403
+ * The returned ids are honest catalog ids (verified present):
41404
+ * - `yolov9m-320-int8` — Intel NPU + iGPU (yolo26 does NOT compile on the NPU)
41405
+ * - `yolov9m-320` — Apple ANE (CoreML)
41406
+ * - `ssd-mobilenet-v2-coco-edgetpu` — Coral USB Edge TPU (tflite)
41407
+ * - `yolo26n` — CPU / CUDA (the object-detection step's universal
41408
+ * nano default)
41409
+ *
41410
+ * Do not promote the accelerated ids to 640. The evaluation in
41411
+ * `docs/benchmarks/pipeline-frame-model-eval.md` failed the 640 promotion
41412
+ * gates (0/3 miss recovered at the current threshold).
41413
+ */
41414
+ /**
41415
+ * The always-on object-detection ROOT step id. A camera session's tracks all
41416
+ * originate from this detector, so a device whose engine format can't run it
41417
+ * cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
41418
+ * import is forbidden — this is the same duplication rationale as the model
41419
+ * defaults above).
41420
+ */
41421
+ var OBJECT_DETECTION_STEP_ID = "object-detection";
41422
+ /**
41423
+ * Build the camera-root capability predicate for a node from its live catalog:
41424
+ * `format → canHostCameraRoot`. A format can host a camera root iff the
41425
+ * catalog lists at least one object-detection model with a build for that
41426
+ * format — byte-for-byte the resolver's per-device skip-gate test for the root
41427
+ * step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
41428
+ * would ACTUALLY provision on it.
41429
+ *
41430
+ * Fails OPEN when the catalog has no object-detection slot at all (never
41431
+ * observed in production) so a malformed/empty catalog never strands every
41432
+ * device off the balancer. Pure + deterministic.
41433
+ */
41434
+ function makeRootCapabilityGuard(catalog) {
41435
+ for (const slot of catalog.slots) {
41436
+ const objDet = slot.addons.find((a) => a.id === OBJECT_DETECTION_STEP_ID);
41437
+ if (objDet) return (format) => objDet.models.some((m) => Boolean(m.formats[format]));
41438
+ }
41439
+ return () => true;
41440
+ }
41441
+ /** Split a deviceKey (`<backend>:<device>`, or bare `cpu`) into its parts + format.
41442
+ * Format comes from the shared {@link deviceBackendToFormat} SSOT (`@camstack/types`)
41443
+ * — the previously-local `BACKEND_FORMAT` copy is gone (R3/node-F2). Used only for
41444
+ * STORED-ONLY keys (a configured device the live probe didn't return); a probed
41445
+ * device carries its own honest `format` from the descriptor. */
41446
+ function parseDeviceKey(deviceKey) {
41447
+ const colon = deviceKey.indexOf(":");
41448
+ const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
41449
+ return {
41450
+ backend,
41451
+ device: colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey,
41452
+ format: deviceBackendToFormat(backend)
41453
+ };
41454
+ }
41455
+ /**
41456
+ * The object-detection model the executor defaults to for a deviceKey. Mirrors
41457
+ * the executor's `MODEL_BY_CLASS` classification (`classifyAccelerator`) plus
41458
+ * the tflite `defaultModelIdByFormat` for Coral. Never throws; unknown backends
41459
+ * fall back to the universal nano default (`yolo26n`).
41460
+ */
41461
+ function defaultModelIdForDevice(deviceKey) {
41462
+ const { backend, device } = parseDeviceKey(deviceKey);
41463
+ if (backend === "openvino") {
41464
+ if (device === "cpu") return "yolo26n";
41465
+ return "yolov9m-320-int8";
41466
+ }
41467
+ if (backend === "edgetpu") return "ssd-mobilenet-v2-coco-edgetpu";
41468
+ if (backend === "coreml") return "yolov9m-320";
41469
+ return "yolo26n";
41470
+ }
41471
+ /**
41472
+ * Step-tree device jump (phase 1): validate every `steps[step].jumpDeviceKey`
41473
+ * manual override in a to-be-saved `inferenceDevices` map. A jump target MUST be
41474
+ * an enabled∧available device on the SAME node and DIFFERENT from the owning
41475
+ * device. `enabledAvailableKeys` is the effective enabled∧available set (from
41476
+ * `mergeInferenceDevices(probe, submitted)`) so an absent/unplugged/disabled
41477
+ * target is rejected honestly (an operator can't route a step onto a dead pool).
41478
+ * Returns the FIRST human-readable error, or `null` when every override is
41479
+ * valid. Pure + deterministic.
41480
+ */
41481
+ function validateJumpTargets(inferenceDevices, enabledAvailableKeys) {
41482
+ for (const [deviceKey, entry] of Object.entries(inferenceDevices)) for (const [stepId, step] of Object.entries(entry.steps ?? {})) {
41483
+ const target = step.jumpDeviceKey;
41484
+ if (target === void 0) continue;
41485
+ if (target === deviceKey) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey pointing at its own device`;
41486
+ if (!enabledAvailableKeys.has(target)) return `step "${stepId}" on "${deviceKey}" has a jumpDeviceKey "${target}" that is not an enabled, available device on this node`;
41487
+ }
41488
+ return null;
41489
+ }
41490
+ /**
41491
+ * Merge a node's live-probed inference devices with its stored per-device map.
41492
+ *
41493
+ * The default is **AUTO = all discovered ACCELERATORS enabled** (spec C2,
41494
+ * opt-OUT) with TWO deliberate exceptions, both **opt-IN** (default disabled):
41495
+ *
41496
+ * - **CPU**: `enumerateInferenceDevices` always emits a universal `cpu`
41497
+ * floor on every platform; auto-enabling it would let the balancer
41498
+ * round-robin ~1/N of sessions onto the slow CPU pool alongside the
41499
+ * NPU/iGPU/ANE. CPU stays the always-available FALLBACK (a node with no
41500
+ * eligible accelerator leaves `deviceKey` unset → the runner's default
41501
+ * pool, which is CPU), not a balanced target — matching the spec's "no
41502
+ * device eligible → fall back to CPU".
41503
+ * - **Coral Edge TPU (`edgetpu`)**: the standing rule since the Coral
41504
+ * executor landed is that it surfaces as selectable but is NEVER
41505
+ * auto-picked — it runs a DIFFERENT, weaker model family (tflite SSD
41506
+ * MobileNet, not the YOLO the other accelerators run), so silently
41507
+ * enrolling a plugged-in Coral changes detection QUALITY, not just
41508
+ * placement. The opt-OUT default did exactly that on 2026-08-01: a hub
41509
+ * Coral nobody enabled entered the session rotation and camera 615 spent
41510
+ * hours at 2.4fps failing tflite model resolution. An operator who wants
41511
+ * the Coral balanced opts it in explicitly (`enabled: true`).
41512
+ *
41513
+ * So: an NPU/iGPU/ANE accelerator with NO stored entry is `enabled:true`; a
41514
+ * CPU or edgetpu device with no stored entry is `enabled:false`; an explicit
41515
+ * stored `enabled` always wins (an operator can opt CPU/Coral in, or an
41516
+ * accelerator out). A stored-only key (configured but the probe did not
41517
+ * return it — removed/unplugged HW) keeps its stored `enabled` and surfaces
41518
+ * as `available:false`, so the UI still shows it.
41519
+ *
41520
+ * Pure + deterministic (sorted by key) — the single merge authority shared by
41521
+ * the `getNodeInferenceDevices` view and the dispatcher's eligible-device pick.
41522
+ */
41523
+ function mergeInferenceDevices(probed, stored) {
41524
+ const probedByKey = new Map(probed.map((d) => [d.key, d]));
41525
+ const keys = new Set([...probedByKey.keys(), ...Object.keys(stored)]);
41526
+ const out = [];
41527
+ for (const key of Array.from(keys).toSorted()) {
41528
+ const descriptor = probedByKey.get(key);
41529
+ const opt = stored[key];
41530
+ const parsed = descriptor ?? parseDeviceKey(key);
41531
+ const weight = opt?.weight !== void 0 && opt.weight > 0 ? opt.weight : 1;
41532
+ const autoDefault = parsed.backend !== "cpu" && parsed.backend !== "edgetpu";
41533
+ out.push({
41534
+ key,
41535
+ backend: parsed.backend,
41536
+ device: parsed.device,
41537
+ format: parsed.format,
41538
+ available: descriptor?.available ?? false,
41539
+ enabled: opt?.enabled ?? autoDefault,
41540
+ weight,
41541
+ maxSessions: opt?.maxSessions ?? null,
41542
+ defaultModelId: defaultModelIdForDevice(key),
41543
+ ...opt?.steps && Object.keys(opt.steps).length > 0 ? { steps: { ...opt.steps } } : {}
41544
+ });
41545
+ }
41546
+ return out;
41547
+ }
41548
+ /**
41549
+ * The per-device concurrent-session caps for a node as `deviceKey → maxSessions`
41550
+ * (only devices that carry an explicit cap; absent = unlimited). Fed to the
41551
+ * device balancer's `nodeCaps` so a device at its cap is skipped (audit F3).
41552
+ */
41553
+ function inferenceDeviceCaps(stored) {
41554
+ const out = {};
41555
+ for (const [key, entry] of Object.entries(stored)) if (entry.maxSessions !== void 0 && entry.maxSessions > 0) out[key] = entry.maxSessions;
41556
+ return out;
41557
+ }
41558
+ /** Is this device the CPU fallback rather than a real accelerator? */
41559
+ function isCpuFallback(view) {
41560
+ return view.backend === "cpu";
41561
+ }
41562
+ function resolveInferenceDeviceEligibility(probed, stored, canRunRoot, isPoolUsable) {
41563
+ const eligible = {};
41564
+ const excluded = [];
41565
+ const merged = mergeInferenceDevices(probed, stored);
41566
+ const acceleratorServes = merged.some((d) => !isCpuFallback(d) && d.enabled && d.available && (!canRunRoot || canRunRoot(d.format)));
41567
+ for (const d of merged) {
41568
+ if (!d.enabled) {
41569
+ excluded.push({
41570
+ key: d.key,
41571
+ reason: "disabled",
41572
+ format: d.format
41573
+ });
41574
+ continue;
41575
+ }
41576
+ if (!d.available) {
41577
+ excluded.push({
41578
+ key: d.key,
41579
+ reason: "unavailable",
41580
+ format: d.format
41581
+ });
41582
+ continue;
41291
41583
  }
41292
- await this.withAudioSubLock(deviceId, async () => {
41293
- const unsub = this.audioSubscriptions.get(deviceId);
41294
- if (!unsub) return;
41295
- try {
41296
- unsub();
41297
- } catch {}
41298
- this.audioSubscriptions.delete(deviceId);
41299
- this.deps.logger.info("lazy audio: window closed", {
41300
- tags: { deviceId },
41301
- meta: { reason }
41584
+ if (isPoolUsable && !isPoolUsable(d.key)) {
41585
+ excluded.push({
41586
+ key: d.key,
41587
+ reason: "unavailable",
41588
+ format: d.format
41302
41589
  });
41303
- });
41304
- }
41305
- /**
41306
- * Audio teardown for one device — the audio half of `stopDetection`.
41307
- * Tears down through the per-device lock so it can't race a concurrent
41308
- * subscribe (which would re-store a handle this teardown never sees).
41309
- */
41310
- async stopForDevice(deviceId) {
41311
- await this.withAudioSubLock(deviceId, async () => {
41312
- const unsub = this.audioSubscriptions.get(deviceId);
41313
- if (unsub) {
41314
- try {
41315
- unsub();
41316
- } catch {}
41317
- this.audioSubscriptions.delete(deviceId);
41318
- }
41319
- });
41320
- const lazyTimer = this.lazyAudioTeardownTimers.get(deviceId);
41321
- if (lazyTimer) {
41322
- clearTimeout(lazyTimer);
41323
- this.lazyAudioTeardownTimers.delete(deviceId);
41590
+ continue;
41324
41591
  }
41325
- const windowTimer = this.motionAudioWindowTimers.get(deviceId);
41326
- if (windowTimer) {
41327
- clearTimeout(windowTimer);
41328
- this.motionAudioWindowTimers.delete(deviceId);
41592
+ if (canRunRoot && !canRunRoot(d.format)) {
41593
+ excluded.push({
41594
+ key: d.key,
41595
+ reason: "cannot-host-camera-root",
41596
+ format: d.format
41597
+ });
41598
+ continue;
41329
41599
  }
41330
- this.audioAssignments.delete(deviceId);
41331
- }
41332
- /**
41333
- * Centralized write into `audioSubscriptions`. If shutdown has begun, the
41334
- * map has already been (or is about to be) cleared lock-free in
41335
- * `shutdown()`; storing here would leak a zombie entry whose `unsub` is
41336
- * never called. So when shutting down we immediately invoke `unsub`
41337
- * (best-effort, error-swallowed) and DO NOT store. `protected` so
41338
- * `audio-sub-lock.spec.ts`'s test subclass can assert the shutdown-guard
41339
- * behavior without casts.
41340
- */
41341
- storeAudioSub(deviceId, unsub) {
41342
- if (this.audioShuttingDown) {
41343
- try {
41344
- unsub();
41345
- } catch {}
41346
- return;
41600
+ if (isCpuFallback(d) && acceleratorServes) {
41601
+ excluded.push({
41602
+ key: d.key,
41603
+ reason: "accelerator-preferred",
41604
+ format: d.format
41605
+ });
41606
+ continue;
41347
41607
  }
41348
- this.audioSubscriptions.set(deviceId, unsub);
41349
- }
41350
- /**
41351
- * Serialize an audio-subscription critical section per device. `fn` is
41352
- * chained onto the device's current lock tail, so concurrent calls for the
41353
- * SAME deviceId run sequentially (FIFO); different deviceIds never block
41354
- * each other. Thin delegate onto the `audioSubLocks` `KeyedAsyncLock`
41355
- * instance. `protected` so `audio-sub-lock.spec.ts`'s test subclass can
41356
- * drive the lock without casts.
41357
- */
41358
- withAudioSubLock(deviceId, fn) {
41359
- return this.audioSubLocks.run(deviceId, fn);
41608
+ eligible[d.key] = d.weight;
41360
41609
  }
41610
+ return {
41611
+ eligible,
41612
+ excluded
41613
+ };
41614
+ }
41615
+ /**
41616
+ * Join the merged device rows with the eligibility verdict so the UI can NAME
41617
+ * why an accelerator is not in play instead of leaving the operator to deduce
41618
+ * it from `enabled`/`available`.
41619
+ *
41620
+ * Deduction is not possible for two of the four reasons — `accelerator-preferred`
41621
+ * is a node-WIDE rule (a CPU row reads `enabled:true, available:true` and still
41622
+ * never gets a session, D215) and `cannot-host-camera-root` needs the node's
41623
+ * model catalog. Both live in {@link resolveInferenceDeviceEligibility}, so this
41624
+ * function only transports its answer; it never re-derives one.
41625
+ *
41626
+ * Pure; preserves `merged`'s order (sorted by key) and every other field.
41627
+ */
41628
+ function annotateInferenceDeviceExclusions(merged, eligibility) {
41629
+ const reasonByKey = new Map(eligibility.excluded.map((e) => [e.key, e.reason]));
41630
+ return merged.map((view) => ({
41631
+ ...view,
41632
+ exclusion: reasonByKey.get(view.key) ?? null
41633
+ }));
41634
+ }
41635
+ function resolveNodeInferenceUsability(eligibility) {
41636
+ const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
41637
+ const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
41638
+ return {
41639
+ usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
41640
+ unavailableKeys,
41641
+ eligibleKeys
41642
+ };
41643
+ }
41644
+ /**
41645
+ * Step-tree device jump (phase 1): the attach-payload roster of a node's
41646
+ * enabled∧available inference devices with the balancer knobs (`weight`,
41647
+ * `maxSessions`) the runner uses to AUTO-jump an enrichment step off a device
41648
+ * whose format can't run it. Built from the SAME `eligible` (deviceKey→weight)
41649
+ * and `caps` (deviceKey→maxSessions) the dispatcher already computes, so the
41650
+ * roster the runner sees exactly matches the balancer's candidate set. Sorted
41651
+ * by key for determinism. Populated onto `RunnerCameraConfig.inferenceDevices`
41652
+ * ONLY when a `deviceKey` is elected and there are ≥2 entries.
41653
+ */
41654
+ function buildInferenceDeviceRoster(eligible, caps) {
41655
+ return Object.entries(eligible).map(([deviceKey, weight]) => ({
41656
+ deviceKey,
41657
+ weight: weight > 0 ? weight : 1,
41658
+ maxSessions: caps[deviceKey] ?? null
41659
+ })).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
41660
+ }
41661
+ //#endregion
41662
+ //#region src/node-inference-usability-mirror.ts
41663
+ var NodeInferenceUsabilityMirror = class {
41664
+ state = /* @__PURE__ */ new Map();
41361
41665
  /**
41362
- * Subscribe to decoded audio chunks for a camera and feed them into the
41363
- * audio-analyzer. Reads the analyzer's settings via its own
41364
- * `resolveDeviceSettings(deviceId)` method so the orchestrator does not
41365
- * touch the audio-analyzer schema field names directly.
41666
+ * Fold one observation in and report whether the caller should act.
41667
+ * Never throws.
41366
41668
  */
41367
- async subscribeAudioStream(deviceId, config) {
41368
- const api = this.deps.api();
41369
- if (!api) {
41370
- this.deps.logger.warn("this.ctx.api not available — cannot subscribe audio", { tags: { deviceId } });
41371
- return null;
41372
- }
41373
- if (!await this.deps.isAudioAnalysisActive(deviceId)) return null;
41374
- if (config.audioMode === "disabled") {
41375
- this.deps.logger.debug("audio subscribe skipped: audioMode=disabled", { tags: { deviceId } });
41376
- return null;
41669
+ observe(nodeId, usable) {
41670
+ const prev = this.state.get(nodeId);
41671
+ if (usable) {
41672
+ this.state.set(nodeId, {
41673
+ usable: true,
41674
+ armed: false
41675
+ });
41676
+ return prev !== void 0 && !prev.usable ? "recovered" : null;
41377
41677
  }
41378
- if (config.audioMode === "on-motion" && !this.isMotionAudioWindowOpen(deviceId)) {
41379
- this.deps.logger.info("audio subscribe deferred: audioMode=on-motion, no window open", { tags: { deviceId } });
41678
+ if (prev === void 0) {
41679
+ this.state.set(nodeId, {
41680
+ usable: true,
41681
+ armed: true
41682
+ });
41380
41683
  return null;
41381
41684
  }
41382
- const audioStream = config.audioStreamId ?? config.motionStreamId;
41383
- const audioBrokerId = makeSourceBrokerId(deviceId, audioStream);
41384
- if ((await this.deps.probeAudioTrack(deviceId, audioStream)).kind === "absent") {
41385
- this.deps.logger.warn("audio subscription REFUSED — this stream carries no audio track", {
41386
- tags: { deviceId },
41387
- meta: {
41388
- camStreamId: audioStream,
41389
- brokerId: audioBrokerId,
41390
- selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
41391
- hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
41392
- }
41685
+ if (!prev.usable) {
41686
+ this.state.set(nodeId, {
41687
+ usable: false,
41688
+ armed: true
41393
41689
  });
41394
41690
  return null;
41395
41691
  }
41396
- const settings = await api.audioAnalysis.resolveDeviceSettings.query({ deviceId });
41397
- if (!settings) {
41398
- this.deps.logger.warn("audio-analysis returned no settings — audio subscription skipped", { tags: { deviceId } });
41692
+ if (!prev.armed) {
41693
+ this.state.set(nodeId, {
41694
+ usable: true,
41695
+ armed: true
41696
+ });
41399
41697
  return null;
41400
41698
  }
41401
- const audioNodeId = await this.dispatch(deviceId);
41402
- const isRemoteAudio = audioNodeId !== this.deps.localNodeId();
41403
- this.deps.logger.info("audio subscription: resolved audio node", {
41404
- tags: { deviceId },
41405
- meta: {
41406
- audioNodeId,
41407
- isRemote: isRemoteAudio
41408
- }
41409
- });
41410
- const accumulator = new AudioWindowAccumulator(deviceId);
41411
- const teardown = startAudioChunkPoller({
41412
- api,
41413
- brokerId: audioBrokerId,
41414
- tag: "audio-analyzer",
41415
- ownerNodeId: this.deps.ingestNode(),
41416
- logger: this.deps.logger.withTags({ deviceId }),
41417
- onChunk: async (chunk) => {
41418
- this.deps.watchdogNote(deviceId, "audio");
41419
- try {
41420
- const audioChunkInput = accumulator.push(chunk);
41421
- if (!audioChunkInput) return;
41422
- const result = await api.audioAnalyzer.analyseChunk.mutate({
41423
- chunk: audioChunkInput,
41424
- settings,
41425
- ...isRemoteAudio ? { nodeId: audioNodeId } : {}
41426
- });
41427
- if (!result) return;
41428
- const frame = buildAudioResultFrame(deviceId, result);
41429
- this.deps.eventBus.emit({
41430
- id: `audio-inference-${deviceId}-${Date.now()}`,
41431
- timestamp: /* @__PURE__ */ new Date(),
41432
- source: {
41433
- type: "device",
41434
- id: deviceId,
41435
- nodeId: "hub",
41436
- addonId: "pipeline-orchestrator",
41437
- deviceId
41438
- },
41439
- category: EventCategory.PipelineAudioInferenceResult,
41440
- data: {
41441
- deviceId,
41442
- frame,
41443
- nodeId: "hub"
41444
- }
41445
- });
41446
- } catch (err) {
41447
- const msg = errMsg(err);
41448
- this.deps.logger.error("Audio analysis failed", {
41449
- tags: { deviceId },
41450
- meta: { error: msg }
41451
- });
41452
- }
41453
- }
41699
+ this.state.set(nodeId, {
41700
+ usable: false,
41701
+ armed: true
41454
41702
  });
41455
- this.deps.logger.info("Audio stream subscribed", { tags: { deviceId } });
41456
- return () => {
41457
- teardown();
41458
- accumulator.reset();
41459
- };
41703
+ return "became-unusable";
41460
41704
  }
41461
- /**
41462
- * Set true at the very start of `onShutdown`, before the audio teardown /
41463
- * map clears below. Once set, `withAudioSubLock` turns queued/new critical
41464
- * sections into no-ops and `storeAudioSub` refuses to store, so no
41465
- * critical section that was in-flight (or queued) when shutdown began can
41466
- * resurrect a zombie subscription into the cleared `audioSubscriptions`
41467
- * map. MUST be called before anything else in `onShutdown` that could
41468
- * race a queued audio critical section (mirrors the original
41469
- * `this.audioShuttingDown = true` being the very first statement).
41470
- */
41471
- beginShutdown() {
41472
- this.audioShuttingDown = true;
41705
+ /** Can this node be given cameras? Unknown nodes answer YES. */
41706
+ isUsable(nodeId) {
41707
+ return this.state.get(nodeId)?.usable ?? true;
41708
+ }
41709
+ /** Nodes currently excluded for the placement log and diagnostics. */
41710
+ unusableNodeIds() {
41711
+ const out = [];
41712
+ for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
41713
+ return out.toSorted();
41714
+ }
41715
+ forget(nodeId) {
41716
+ this.state.delete(nodeId);
41473
41717
  }
41718
+ reset() {
41719
+ this.state.clear();
41720
+ }
41721
+ };
41722
+ //#endregion
41723
+ //#region src/inference-device-usability-mirror.ts
41724
+ /**
41725
+ * InferenceDeviceUsabilityMirror — "can this NODE's THIS DEVICE still infer?",
41726
+ * kept off the placement path.
41727
+ *
41728
+ * The per-`(nodeId, deviceKey)` twin of {@link NodeInferenceUsabilityMirror},
41729
+ * and deliberately not a second mechanism: it composes the key and delegates
41730
+ * every decision to that class, so the arm/apply reluctance D49 pinned lives in
41731
+ * exactly one implementation and cannot drift between the node tier and the
41732
+ * device tier.
41733
+ *
41734
+ * ## Why this tier had to exist
41735
+ *
41736
+ * The node tier already answers "does this node have ANY usable accelerator".
41737
+ * On 2026-08-25 the hub's answer was, correctly, yes — its NPU was healthy the
41738
+ * whole time. What died was one DEVICE, `openvino:gpu`, and nothing anywhere
41739
+ * asked that question: the per-dispatch capability gate is keyed on model
41740
+ * FORMAT and `openvino:gpu` and `openvino:npu` share the format `openvino`, so
41741
+ * it is blind between them by construction. The balancer kept rotating cameras
41742
+ * onto the dead pool — 20 sessions in 20 minutes, every one `tieBreak:
41743
+ * "rotation"` — for 31 hours.
41744
+ *
41745
+ * ## Why a mirror and not the event
41746
+ *
41747
+ * D49, verbatim: *a one-shot event never gates on a fallible read*. The gate is
41748
+ * this in-memory mirror, refreshed off the event path by the same
41749
+ * `resolveEligibleInferenceDevices` the dispatcher already runs (plus the
41750
+ * session controller's background refresher). The consequences that buys:
41751
+ *
41752
+ * - **A read that fails changes nothing.** The caller folds in an observation
41753
+ * only when it HAS one; an unreachable node, a version-skewed executor or a
41754
+ * rejected RPC never reaches {@link observe}, so the previous verdict
41755
+ * stands. This is the whole reason the health read is specified as
41756
+ * "synchronous over in-memory state, never throws for its own reasons": an
41757
+ * empty answer must mean *nothing is refused*, not *I could not tell*.
41758
+ * - **Excluding a healthy device needs a second, consecutive read.** Exclusion
41759
+ * is the direction that DESTROYS work — it strands an accelerator that may
41760
+ * be perfectly fine — so one bad observation only ARMS.
41761
+ * - **Re-admitting is immediate and unconditional.** One good observation puts
41762
+ * the device straight back. Being slow to exclude costs some wasted
41763
+ * inference attempts; being slow to re-admit costs an idle accelerator and a
41764
+ * node that looks broken.
41765
+ */
41766
+ /**
41767
+ * NUL — impossible in both a Moleculer node id and a `<backend>:<device>` key,
41768
+ * so the composite key can never be ambiguous. A separator that CAN occur in
41769
+ * either half makes two distinct pairs collide, and a collision here silently
41770
+ * excludes an accelerator nobody reported.
41771
+ */
41772
+ var SEPARATOR = "\0";
41773
+ var InferenceDeviceUsabilityMirror = class {
41774
+ /** The one implementation of the arm/apply state machine (D49). */
41775
+ mirror = new NodeInferenceUsabilityMirror();
41474
41776
  /**
41475
- * Full audio teardown combines the former `onShutdown`'s two separate
41476
- * audio blocks (lazy-teardown-timer clear, then — after several unrelated
41477
- * session/reconcile/load-shed clears — subscription teardown + lock clear
41478
- * + assignment-map clears) into one call. Safe to combine: both blocks
41479
- * are synchronous with no interleaved `await`, and every original
41480
- * statement between them (`sessionRegistry.clear()`,
41481
- * `cameraFpsMap.clear()`, `remoteHealthAttempts.clear()`,
41482
- * `loadShedState.clear()`, `loadShedResumeTimer` cleanup) touches state
41483
- * fully disjoint from anything audio — so their relative order to each
41484
- * other is unaffected, and the audio-internal order (timers →
41485
- * subscriptions → lock → assignment maps) is reproduced exactly.
41777
+ * Fold one observation in and report whether the caller should act.
41778
+ * Never throws.
41486
41779
  */
41487
- shutdown() {
41488
- for (const t of this.lazyAudioTeardownTimers.values()) clearTimeout(t);
41489
- this.lazyAudioTeardownTimers.clear();
41490
- for (const t of this.motionAudioWindowTimers.values()) clearTimeout(t);
41491
- this.motionAudioWindowTimers.clear();
41492
- for (const unsub of this.audioSubscriptions.values()) try {
41493
- unsub();
41494
- } catch {}
41495
- this.audioSubscriptions.clear();
41496
- this.audioSubLocks.clear();
41497
- this.audioAssignments.clear();
41498
- this.readyAudioNodes.clear();
41780
+ observe(nodeId, deviceKey, usable) {
41781
+ return this.mirror.observe(`${nodeId}${SEPARATOR}${deviceKey}`, usable);
41782
+ }
41783
+ /** Can the balancer put a session on this device? Unknown pairs answer YES. */
41784
+ isUsable(nodeId, deviceKey) {
41785
+ return this.mirror.isUsable(`${nodeId}${SEPARATOR}${deviceKey}`);
41786
+ }
41787
+ /** Pairs currently excluded — for the placement log and diagnostics. */
41788
+ unusableDevices() {
41789
+ return this.mirror.unusableNodeIds().map((composite) => {
41790
+ const at = composite.indexOf(SEPARATOR);
41791
+ return {
41792
+ nodeId: composite.slice(0, at),
41793
+ deviceKey: composite.slice(at + 1)
41794
+ };
41795
+ });
41796
+ }
41797
+ /** The excluded device keys on ONE node. */
41798
+ unusableDeviceKeys(nodeId) {
41799
+ return this.unusableDevices().filter((d) => d.nodeId === nodeId).map((d) => d.deviceKey);
41800
+ }
41801
+ forget(nodeId, deviceKey) {
41802
+ this.mirror.forget(`${nodeId}${SEPARATOR}${deviceKey}`);
41803
+ }
41804
+ reset() {
41805
+ this.mirror.reset();
41499
41806
  }
41500
41807
  };
41808
+ /**
41809
+ * Fold ONE node's health answer into the mirror and return what changed.
41810
+ *
41811
+ * This is the whole reading discipline, in one place, because both halves of it
41812
+ * are easy to get subtly wrong and neither failure is visible in a log:
41813
+ *
41814
+ * - **`unhealthy === null` — the read FAILED — changes nothing.** Not a single
41815
+ * entry is touched. An unreachable node, a version-skewed executor or a
41816
+ * rejected RPC must be distinguishable from "asked, nothing is refused", or
41817
+ * a flaky link silently re-admits a dead accelerator (D49).
41818
+ * - **Every device the node HAS is observed**, not merely the refused ones.
41819
+ * The first draft observed `refused ∪ already-excluded`, which omits exactly
41820
+ * the devices the mirror has ARMED — so their disarming good read never
41821
+ * arrived and two bad reads an HOUR apart, with a hundred healthy ones
41822
+ * between them, excluded a working accelerator. "Consecutive" is only a
41823
+ * property if the good observations are delivered.
41824
+ *
41825
+ * Pure with respect to everything except `mirror`, and never throws — it is
41826
+ * called from the dispatcher's own read path.
41827
+ */
41828
+ function foldInferenceDeviceHealth(mirror, nodeId, unhealthy, present) {
41829
+ if (unhealthy === null) return [];
41830
+ const refused = new Set(unhealthy);
41831
+ const observed = new Set([
41832
+ ...present,
41833
+ ...refused,
41834
+ ...mirror.unusableDeviceKeys(nodeId)
41835
+ ]);
41836
+ const changes = [];
41837
+ for (const deviceKey of observed) {
41838
+ const transition = mirror.observe(nodeId, deviceKey, !refused.has(deviceKey));
41839
+ if (transition !== null) changes.push({
41840
+ deviceKey,
41841
+ transition
41842
+ });
41843
+ }
41844
+ return changes;
41845
+ }
41501
41846
  //#endregion
41502
41847
  //#region src/load-balancer.ts
41503
41848
  /**
@@ -43433,6 +43778,90 @@ function applyDeviceProvisioning(steps, base, override) {
43433
43778
  });
43434
43779
  }
43435
43780
  //#endregion
43781
+ //#region src/device-activity-source.ts
43782
+ /** The source's own name on the wire. One constant, used by every emit + gate. */
43783
+ var DEVICE_ACTIVITY_SOURCE = "device-activity";
43784
+ var DeviceActivitySource = class {
43785
+ #deps;
43786
+ #devices = /* @__PURE__ */ new Map();
43787
+ constructor(deps) {
43788
+ this.#deps = deps;
43789
+ }
43790
+ /**
43791
+ * A `recording-signal` level landed for a device. Idempotent in the level:
43792
+ * only a CHANGE emits an edge, and the sustain tick — not a repeated push —
43793
+ * is what keeps the session open.
43794
+ */
43795
+ onSignalLevel(deviceId, active, reason, atMs) {
43796
+ const entry = this.#devices.get(deviceId) ?? {
43797
+ active: false,
43798
+ timer: null
43799
+ };
43800
+ const wasActive = entry.active;
43801
+ entry.active = active;
43802
+ this.#devices.set(deviceId, entry);
43803
+ if (active === wasActive) return;
43804
+ if (active) {
43805
+ this.#deps.logger.info("device-activity: the device reports it is working", {
43806
+ tags: { deviceId },
43807
+ meta: { reason }
43808
+ });
43809
+ this.#emit(deviceId, true, atMs);
43810
+ this.#arm(deviceId, entry);
43811
+ return;
43812
+ }
43813
+ this.#clear(entry);
43814
+ this.#deps.logger.info("device-activity: the device reports it has stopped", {
43815
+ tags: { deviceId },
43816
+ meta: { reason }
43817
+ });
43818
+ this.#emit(deviceId, false, atMs);
43819
+ }
43820
+ /** Drop every timer (addon teardown). The mirror goes with the instance. */
43821
+ stop() {
43822
+ for (const entry of this.#devices.values()) this.#clear(entry);
43823
+ this.#devices.clear();
43824
+ }
43825
+ #arm(deviceId, entry) {
43826
+ this.#clear(entry);
43827
+ const sustainMs = this.#deps.sustainMsFor(deviceId);
43828
+ const timer = setInterval(() => {
43829
+ const current = this.#devices.get(deviceId);
43830
+ if (!current || !current.active) {
43831
+ this.#clear(current ?? entry);
43832
+ return;
43833
+ }
43834
+ this.#emit(deviceId, true, Date.now());
43835
+ }, sustainMs);
43836
+ timer.unref?.();
43837
+ entry.timer = timer;
43838
+ }
43839
+ #clear(entry) {
43840
+ if (entry.timer === null) return;
43841
+ clearInterval(entry.timer);
43842
+ entry.timer = null;
43843
+ }
43844
+ /**
43845
+ * Emit, unless this camera does not carry the source. A suppressed emit is
43846
+ * SAID — an operator who never ticked the box and an addon that is quietly
43847
+ * broken look identical from the outside otherwise.
43848
+ */
43849
+ #emit(deviceId, detected, timestamp) {
43850
+ const sources = this.#deps.motionSourcesFor(deviceId);
43851
+ if (sources === null || !sources.includes("device-activity")) {
43852
+ this.#deps.logger.debug("device-activity: level not forwarded — the camera does not list `device-activity`", {
43853
+ tags: { deviceId },
43854
+ meta: {
43855
+ detected,
43856
+ sources: sources ?? "no-active-detection"
43857
+ }
43858
+ });
43859
+ return;
43860
+ }
43861
+ this.#deps.emitMotion(deviceId, detected, timestamp);
43862
+ }
43863
+ };
43864
+ //#endregion
43436
43865
  //#region src/device-detection-settings.ts
43437
43866
  /** Read a required string leaf out of the hydrated `flat` schema values. */
43438
43867
  function mustString(flat, deviceId, key) {
@@ -43458,6 +43887,32 @@ function numberOrDefault(flat, key) {
43458
43887
  const dflt = uiField && "default" in uiField ? uiField.default : void 0;
43459
43888
  return typeof dflt === "number" ? dflt : 0;
43460
43889
  }
43890
+ /** The activity rate from the hydrated store, or the shipped default. */
43891
+ function activityFps(flat) {
43892
+ const v = flat["activityDetectionFps"];
43893
+ return typeof v === "number" && v > 0 ? v : 1;
43894
+ }
43895
+ /**
43896
+ * The detection rate a session opens at, given WHAT OPENED IT.
43897
+ *
43898
+ * A CAP, not a set: `min(cameraRate, activityRate)`. A camera already slower
43899
+ * than the activity rate stays slower, so lowering the global rate keeps
43900
+ * working. Why this lever and not the other two: a per-device `detectionFps`
43901
+ * would also slow the sessions a real motion trigger opens on the same camera,
43902
+ * and it becomes silently wrong the day the device gains a second trigger; a
43903
+ * per-SOURCE rate table would have to reach the runner and be re-applied
43904
+ * whenever the source holding the session changes, which the attach-time config
43905
+ * cannot express. Scoping it to the session's TRIGGER puts the number exactly
43906
+ * where its justification lives.
43907
+ *
43908
+ * Applies to the session the activity level OPENS. A session already open at
43909
+ * the camera rate when the level rises is not re-attached to slow it down —
43910
+ * re-attaching a live session to change one number costs a decode restart.
43911
+ */
43912
+ function detectionFpsForTrigger(config, trigger) {
43913
+ if (trigger !== "device-activity") return config.detectionFps;
43914
+ return Math.min(config.detectionFps, config.activityDetectionFps ?? 1);
43915
+ }
43461
43916
  /**
43462
43917
  * Pure decision/derivation half of the former `resolveDeviceDetectionSettings`.
43463
43918
  * Given the I/O-gathered raw materials, resolves every operator-wins →
@@ -43467,14 +43922,19 @@ function numberOrDefault(flat, key) {
43467
43922
  * the original method's "narrowing failed" catch.
43468
43923
  */
43469
43924
  function resolveDetectionSettings(input) {
43470
- const { deviceId, raw, flat, features, hasOnboardMotion, pipelineEnabled, motionDetectionEnabled } = input;
43925
+ const { deviceId, raw, flat, features, hasOnboardMotion, hasActivitySignal, pipelineEnabled, motionDetectionEnabled } = input;
43471
43926
  const profile = resolveDeviceProfile(features);
43472
43927
  const userMotionSources = raw["motionSources"];
43473
43928
  let motionSources;
43474
43929
  if (userMotionSources !== void 0) motionSources = MotionSourcesSchema.parse(userMotionSources);
43475
- else if (hasOnboardMotion) motionSources = ["onboard"];
43476
- else if (profile && features.includes(DeviceFeature.BatteryOperated)) motionSources = [];
43477
- else motionSources = MotionSourcesSchema.parse(flat["motionSources"]);
43930
+ else {
43931
+ let defaulted;
43932
+ if (hasOnboardMotion) defaulted = ["onboard"];
43933
+ else if (hasActivitySignal) defaulted = [];
43934
+ else if (profile && features.includes(DeviceFeature.BatteryOperated)) defaulted = [];
43935
+ else defaulted = MotionSourcesSchema.parse(flat["motionSources"]);
43936
+ motionSources = hasActivitySignal ? [...defaulted, DEVICE_ACTIVITY_SOURCE] : defaulted;
43937
+ }
43478
43938
  const userDetectionMode = raw["detectionMode"];
43479
43939
  const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
43480
43940
  const userAudioMode = raw["audioMode"];
@@ -43493,6 +43953,7 @@ function resolveDetectionSettings(input) {
43493
43953
  detectionStreamProfile: mustString(flat, deviceId, "detectionStreamProfile"),
43494
43954
  motionFps: numberOrDefault(flat, "motionFps"),
43495
43955
  detectionFps: numberOrDefault(flat, "detectionFps"),
43956
+ activityDetectionFps: activityFps(flat),
43496
43957
  motionCooldownMs: numberOrDefault(flat, "motionCooldownMs"),
43497
43958
  maxSessionHoldMs: numberOrDefault(flat, "maxSessionHoldMs"),
43498
43959
  audioMotionWindowMs: numberOrDefault(flat, "audioMotionWindowMs"),
@@ -43549,6 +44010,7 @@ function buildDetectionConfigFromInputs(resolved, assigned) {
43549
44010
  detectionStreamId: detectionCamStreamId,
43550
44011
  motionFps: resolved.motionFps,
43551
44012
  detectionFps: resolved.detectionFps,
44013
+ activityDetectionFps: resolved.activityDetectionFps,
43552
44014
  motionCooldownMs: resolved.motionCooldownMs,
43553
44015
  maxSessionHoldMs: resolved.maxSessionHoldMs,
43554
44016
  audioMotionWindowMs: resolved.audioMotionWindowMs,
@@ -43574,6 +44036,7 @@ function detectionConfigEquals(a, b) {
43574
44036
  if (a.detectionStreamId !== b.detectionStreamId) return false;
43575
44037
  if (a.motionFps !== b.motionFps) return false;
43576
44038
  if (a.detectionFps !== b.detectionFps) return false;
44039
+ if (a.activityDetectionFps !== b.activityDetectionFps) return false;
43577
44040
  if (a.motionCooldownMs !== b.motionCooldownMs) return false;
43578
44041
  if (a.maxSessionHoldMs !== b.maxSessionHoldMs) return false;
43579
44042
  if (a.audioMotionWindowMs !== b.audioMotionWindowMs) return false;
@@ -44200,6 +44663,7 @@ var DetectionWiringController = class {
44200
44663
  try {
44201
44664
  const features = await this.lookupDeviceFeatures(deviceId);
44202
44665
  const hasOnboardMotion = raw["motionSources"] === void 0 ? await this.deps.deviceHasOnboardMotionCap(deviceId) : false;
44666
+ const hasActivitySignal = raw["motionSources"] === void 0 ? await this.deps.deviceHasActivitySignalCap(deviceId) : false;
44203
44667
  const pipelineEnabled = await this.isDetectionPipelineActive(deviceId);
44204
44668
  const motionDetectionEnabled = await this.isMotionDetectionActive(deviceId);
44205
44669
  return resolveDetectionSettings({
@@ -44208,6 +44672,7 @@ var DetectionWiringController = class {
44208
44672
  flat,
44209
44673
  features,
44210
44674
  hasOnboardMotion,
44675
+ hasActivitySignal,
44211
44676
  pipelineEnabled,
44212
44677
  motionDetectionEnabled
44213
44678
  });
@@ -44813,9 +45278,10 @@ var DeviceConfigContributions = class {
44813
45278
  const schema = this.deps.deviceSettingsSchema();
44814
45279
  if (!schema) return null;
44815
45280
  const hasOnboardMotion = await this.deps.deviceHasOnboardMotionCap(input.deviceId);
44816
- const rawWithDefaults = raw["motionSources"] === void 0 && hasOnboardMotion ? {
45281
+ const hasActivitySignal = await this.deps.deviceHasActivitySignalCap(input.deviceId);
45282
+ const rawWithDefaults = raw["motionSources"] === void 0 && (hasOnboardMotion || hasActivitySignal) ? {
44817
45283
  ...raw,
44818
- motionSources: ["onboard"]
45284
+ motionSources: [...hasOnboardMotion ? ["onboard"] : [], ...hasActivitySignal ? ["device-activity"] : []]
44819
45285
  } : raw;
44820
45286
  const baseSections = hydrateSchema({
44821
45287
  ...schema,
@@ -47635,7 +48101,8 @@ function wireOrchestratorSubscriptions(deps) {
47635
48101
  if (!isEvent(event, EventCategory.MotionOnMotionChanged)) return;
47636
48102
  const { deviceId, detected, timestamp } = event.data;
47637
48103
  if (typeof deviceId !== "number") return;
47638
- deps.handleSessionMotion(deviceId, detected, typeof timestamp === "number" ? timestamp : void 0).catch((err) => {
48104
+ const parsedSource = MotionSourceEnum.safeParse(event.data.source);
48105
+ deps.handleSessionMotion(deviceId, detected, typeof timestamp === "number" ? timestamp : void 0, parsedSource.success ? parsedSource.data : void 0).catch((err) => {
47639
48106
  deps.logger.warn("session motion handler failed", {
47640
48107
  tags: { deviceId },
47641
48108
  meta: {
@@ -47657,8 +48124,47 @@ function wireOrchestratorSubscriptions(deps) {
47657
48124
  const deviceId = event.source.deviceId;
47658
48125
  if (typeof deviceId === "number") deps.noteWatchdogSignal(deviceId, "motion");
47659
48126
  });
48127
+ const activitySource = new DeviceActivitySource({
48128
+ logger: deps.logger,
48129
+ emitMotion: (deviceId, detected, timestamp) => {
48130
+ if (isTornDown) return;
48131
+ deps.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, {
48132
+ type: "device",
48133
+ id: deviceId,
48134
+ deviceId
48135
+ }, {
48136
+ deviceId,
48137
+ detected,
48138
+ timestamp,
48139
+ source: DEVICE_ACTIVITY_SOURCE
48140
+ }));
48141
+ },
48142
+ motionSourcesFor: (deviceId) => deps.getActiveDetectionConfig(deviceId)?.motionSources ?? null,
48143
+ sustainMsFor: (deviceId) => {
48144
+ const cooldownMs = deps.getActiveDetectionConfig(deviceId)?.motionCooldownMs;
48145
+ return Math.max(1e3, Math.floor((cooldownMs ?? 3e4) / 2));
48146
+ }
48147
+ });
48148
+ const unsubDeviceActivity = deps.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
48149
+ const data = event.data;
48150
+ if (typeof data !== "object" || data === null) return;
48151
+ if (data["capName"] !== recordingSignalCapability.name) return;
48152
+ const deviceId = data["deviceId"];
48153
+ if (typeof deviceId !== "number") return;
48154
+ const level = RecordingSignalStatusSchema.safeParse(data["slice"]);
48155
+ if (!level.success) {
48156
+ deps.logger.warn("device-activity: signal slice does not parse — ignored", {
48157
+ tags: { deviceId },
48158
+ meta: { slice: data["slice"] }
48159
+ });
48160
+ return;
48161
+ }
48162
+ const atMs = event.timestamp instanceof Date ? event.timestamp.getTime() : Date.now();
48163
+ activitySource.onSignalLevel(deviceId, level.data.active, level.data.reason, atMs);
48164
+ });
47660
48165
  return () => {
47661
48166
  isTornDown = true;
48167
+ activitySource.stop();
47662
48168
  for (const t of profileSlotTimers.values()) clearTimeout(t);
47663
48169
  profileSlotTimers.clear();
47664
48170
  unsubDeviceRegistered();
@@ -47673,6 +48179,7 @@ function wireOrchestratorSubscriptions(deps) {
47673
48179
  unsubSessionMotion();
47674
48180
  unsubFrameTracked();
47675
48181
  unsubMotionAnalysis();
48182
+ unsubDeviceActivity();
47676
48183
  };
47677
48184
  }
47678
48185
  //#endregion
@@ -50432,7 +50939,7 @@ var SessionDispatchController = class {
50432
50939
  * standing attach, so `hasStandingAttach` stays true for them and
50433
50940
  * `decideSessionAction` still returns `'ignore'`.
50434
50941
  */
50435
- async handleSessionMotion(deviceId, detected, emittedAt) {
50942
+ async handleSessionMotion(deviceId, detected, emittedAt, trigger) {
50436
50943
  const receivedAt = Date.now();
50437
50944
  const busLagMs = emittedAt !== void 0 ? receivedAt - emittedAt : void 0;
50438
50945
  const config = this.deps.getActiveDetectionConfig(deviceId);
@@ -50467,7 +50974,7 @@ var SessionDispatchController = class {
50467
50974
  return;
50468
50975
  }
50469
50976
  this.activeRefireCountByDevice.delete(deviceId);
50470
- await this.dispatchDetectionSession(deviceId, cur);
50977
+ await this.dispatchDetectionSession(deviceId, cur, trigger);
50471
50978
  if (this.sessionRegistry.has(deviceId)) this.scheduleSessionTeardown(deviceId, cooldownMs);
50472
50979
  const doneAt = Date.now();
50473
50980
  this.deps.logger.info("session motion → attach latency", {
@@ -50506,7 +51013,7 @@ var SessionDispatchController = class {
50506
51013
  * `dispatchCamera`) avoids any risk of changing that already-live
50507
51014
  * standing-camera path.
50508
51015
  */
50509
- async dispatchDetectionSession(deviceId, config) {
51016
+ async dispatchDetectionSession(deviceId, config, trigger) {
50510
51017
  const log = this.deps.logger.withTags({ deviceId });
50511
51018
  await this.deps.reconcilePlacementFromRunners();
50512
51019
  const preferredAgent = await this.deps.readPipelinePin(deviceId);
@@ -50562,13 +51069,19 @@ var SessionDispatchController = class {
50562
51069
  const steps = applyDeviceProvisioning(pipelineConfig.steps, deviceBase, deviceOverride);
50563
51070
  const inferenceDevices = deviceKey && Object.keys(enabledDevices).length >= 2 ? buildInferenceDeviceRoster(enabledDevices, deviceCaps) : void 0;
50564
51071
  const zones = await this.deps.listZones(deviceId);
51072
+ const sessionDetectionFps = detectionFpsForTrigger(config, trigger);
51073
+ if (sessionDetectionFps !== config.detectionFps) log.info("session opened at the device-activity rate", { meta: {
51074
+ trigger,
51075
+ detectionFps: sessionDetectionFps,
51076
+ cameraDetectionFps: config.detectionFps
51077
+ } });
50565
51078
  const sessionConfig = {
50566
51079
  deviceId,
50567
51080
  ...deviceKey ? { deviceKey } : {},
50568
51081
  ...inferenceDevices ? { inferenceDevices } : {},
50569
51082
  motionCooldownMs: config.motionCooldownMs,
50570
51083
  motionFps: config.motionFps,
50571
- detectionFps: config.detectionFps,
51084
+ detectionFps: sessionDetectionFps,
50572
51085
  motionStreamId: config.motionStreamId,
50573
51086
  detectionStreamId: config.detectionStreamId,
50574
51087
  motionSources: [],
@@ -51873,6 +52386,7 @@ async function buildOrchestratorControllers(deps) {
51873
52386
  localNodeId: () => localNodeId,
51874
52387
  deviceSettingsSchema: () => deps.deviceSettingsSchema(),
51875
52388
  deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
52389
+ deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
51876
52390
  deviceHasNativeObjectDetectionCap: (deviceId) => deps.deviceHasNativeObjectDetectionCap(deviceId),
51877
52391
  setCameraPipelineForAgent: (input) => deps.setCameraPipelineForAgent(input),
51878
52392
  emitCameraUpdated: (deviceId, config) => deps.emitCameraUpdated(deviceId, config),
@@ -52190,6 +52704,7 @@ async function buildOrchestratorControllers(deps) {
52190
52704
  },
52191
52705
  isCapActiveForDevice: (deviceId, capName) => deps.isCapActiveForDevice(deviceId, capName),
52192
52706
  deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
52707
+ deviceHasActivitySignalCap: (deviceId) => deps.deviceHasActivitySignalCap(deviceId),
52193
52708
  deviceSettingsSchema: () => deps.deviceSettingsSchema()
52194
52709
  });
52195
52710
  await detectionWiring.hydrateFeaturesMirror().catch((err) => {
@@ -52225,255 +52740,6 @@ async function buildOrchestratorControllers(deps) {
52225
52740
  };
52226
52741
  }
52227
52742
  //#endregion
52228
- //#region src/viewer-ui-provider.ts
52229
- /**
52230
- * viewer-ui provider — the pipeline-orchestrator serves the CamStack viewer web
52231
- * SPA (mirrors what the now-removed standalone addon-viewer-ui used to do).
52232
- *
52233
- * Why here: the orchestrator is a hub bootstrap addon (always installed + baked),
52234
- * so folding the viewer serving into it avoids a second addon whose only job was
52235
- * to hold static files. The viewer's Expo web export is COPIED into this addon's
52236
- * `assets/viewer/` locally by the viewer repo's `scripts/copy-dist-to-orchestrator.js`
52237
- * (run from a checkout that HAS the `camstack/` submodule + Expo toolchain — the
52238
- * publish/image CI does not, which is exactly why we copy a pre-built dist rather
52239
- * than build it here). vite emits only into `dist/`, so `assets/viewer` survives
52240
- * the addon build; `assets` is in the package `files`, so it ships on
52241
- * `camstack deploy`. The hub's `main.ts` resolves the `viewer-ui` singleton at
52242
- * boot and mounts the SPA at `/viewer/camstack`.
52243
- *
52244
- * `index.js` runs from `dist/`, so the SPA root is one level up + `assets/viewer`.
52245
- */
52246
- var __dirname$1 = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
52247
- /** Absolute path to the staged viewer web SPA (`<addon-root>/assets/viewer`). */
52248
- function resolveViewerDistDir() {
52249
- return node_path.default.resolve(__dirname$1, "..", "assets", "viewer");
52250
- }
52251
- /** Version of the staged viewer, written by the copy script; 'unknown' if absent. */
52252
- function readViewerVersion() {
52253
- try {
52254
- const raw = node_fs.default.readFileSync(node_path.default.join(resolveViewerDistDir(), ".viewer-version"), "utf-8").trim();
52255
- if (raw) return raw;
52256
- } catch {}
52257
- return "unknown";
52258
- }
52259
- /** Build the viewer-ui provider. Serves whatever dist was staged into
52260
- * `assets/viewer`; when nothing is staged the hub's mount cleanly 404s. */
52261
- function createViewerUiProvider() {
52262
- return {
52263
- getStaticDir: async () => ({ staticDir: resolveViewerDistDir() }),
52264
- getVersion: async () => ({ version: readViewerVersion() })
52265
- };
52266
- }
52267
- //#endregion
52268
- //#region src/disk-reconcile-fleet.ts
52269
- async function reconcileFleetFromDisk(deps) {
52270
- const deviceIds = await deps.listDeviceIds();
52271
- const failed = [];
52272
- let cameras = 0;
52273
- let mediaDropped = 0;
52274
- let tracks = 0;
52275
- let events = 0;
52276
- let completed = 0;
52277
- for (const deviceId of deviceIds) {
52278
- try {
52279
- await deps.rescanRecordings(deviceId);
52280
- const counts = await deps.reconcileAnalytics(deviceId);
52281
- cameras += 1;
52282
- mediaDropped += counts.mediaDropped;
52283
- tracks += counts.tracks;
52284
- events += counts.events;
52285
- } catch {
52286
- failed.push(deviceId);
52287
- }
52288
- completed += 1;
52289
- deps.onProgress?.({
52290
- deviceId,
52291
- total: deviceIds.length,
52292
- completed,
52293
- failed: [...failed],
52294
- mediaDropped,
52295
- tracks,
52296
- events
52297
- });
52298
- }
52299
- return {
52300
- cameras,
52301
- failed,
52302
- mediaDropped,
52303
- tracks,
52304
- events
52305
- };
52306
- }
52307
- //#endregion
52308
- //#region src/disk-reconcile-job.ts
52309
- /**
52310
- * In-memory disk-wins fleet job. The tRPC mutation starts this and returns
52311
- * immediately; the walk runs in the addon process so a 60s UDS timeout cannot
52312
- * abort it. Status is polled via getReconcileFromDiskStatus.
52313
- */
52314
- function idleDiskReconcileJob() {
52315
- return {
52316
- state: "idle",
52317
- total: 0,
52318
- completed: 0,
52319
- currentDeviceId: null,
52320
- failed: [],
52321
- mediaDropped: 0,
52322
- tracks: 0,
52323
- events: 0,
52324
- startedAtMs: null,
52325
- finishedAtMs: null,
52326
- error: null
52327
- };
52328
- }
52329
- function isTimeoutError(err) {
52330
- const message = err instanceof Error ? err.message : String(err);
52331
- return /timed out/i.test(message);
52332
- }
52333
- async function withTimeoutRetry(run) {
52334
- try {
52335
- return await run();
52336
- } catch (err) {
52337
- if (!isTimeoutError(err)) throw err;
52338
- return await run();
52339
- }
52340
- }
52341
- function createDiskReconcileJobRunner(now = Date.now) {
52342
- let job = idleDiskReconcileJob();
52343
- let inFlight = null;
52344
- const snapshot = () => job;
52345
- const start = (deps) => {
52346
- if (job.state === "running" && inFlight) return job;
52347
- job = {
52348
- ...idleDiskReconcileJob(),
52349
- state: "running",
52350
- startedAtMs: now()
52351
- };
52352
- deps.log?.("pipeline disk reconcile started");
52353
- inFlight = (async () => {
52354
- try {
52355
- const result = await reconcileFleetFromDisk({
52356
- listDeviceIds: deps.listDeviceIds,
52357
- rescanRecordings: (deviceId) => withTimeoutRetry(() => deps.rescanRecordings(deviceId)),
52358
- reconcileAnalytics: (deviceId) => withTimeoutRetry(() => deps.reconcileAnalytics(deviceId)),
52359
- onProgress: (update) => {
52360
- job = {
52361
- ...job,
52362
- total: update.total,
52363
- completed: update.completed,
52364
- currentDeviceId: update.deviceId,
52365
- failed: update.failed,
52366
- mediaDropped: update.mediaDropped,
52367
- tracks: update.tracks,
52368
- events: update.events
52369
- };
52370
- deps.onProgress?.(update);
52371
- deps.log?.("pipeline disk reconcile camera", {
52372
- deviceId: update.deviceId,
52373
- completed: update.completed,
52374
- total: update.total,
52375
- failed: update.failed.length,
52376
- mediaDropped: update.mediaDropped,
52377
- tracks: update.tracks,
52378
- events: update.events
52379
- });
52380
- }
52381
- });
52382
- job = {
52383
- ...job,
52384
- state: "done",
52385
- total: result.cameras + result.failed.length,
52386
- completed: result.cameras + result.failed.length,
52387
- currentDeviceId: null,
52388
- failed: result.failed,
52389
- mediaDropped: result.mediaDropped,
52390
- tracks: result.tracks,
52391
- events: result.events,
52392
- finishedAtMs: now(),
52393
- error: null
52394
- };
52395
- deps.log?.("pipeline disk reconcile", {
52396
- cameras: result.cameras,
52397
- failed: result.failed,
52398
- mediaDropped: result.mediaDropped,
52399
- tracks: result.tracks,
52400
- events: result.events
52401
- });
52402
- } catch (err) {
52403
- const error = err instanceof Error ? err.message : String(err);
52404
- job = {
52405
- ...job,
52406
- state: "error",
52407
- currentDeviceId: null,
52408
- finishedAtMs: now(),
52409
- error
52410
- };
52411
- deps.log?.("pipeline disk reconcile failed", { error });
52412
- } finally {
52413
- inFlight = null;
52414
- }
52415
- })();
52416
- return job;
52417
- };
52418
- return {
52419
- snapshot,
52420
- start
52421
- };
52422
- }
52423
- //#endregion
52424
- //#region src/widget-catalog.ts
52425
- var pipelineOrchestratorWidgets = [{
52426
- tab: "device-tab",
52427
- label: "Pipeline Quick Stats",
52428
- preAuth: false,
52429
- kind: "remote",
52430
- remote: {
52431
- remoteName: "addon_pipeline_orchestrator_widgets",
52432
- exposedModule: "./widgets",
52433
- componentKey: "pipeline-quick-stats"
52434
- },
52435
- stableId: "pipeline-quick-stats",
52436
- description: "Phase / Detection FPS / Inference / Active Tracks tile row.",
52437
- icon: "activity",
52438
- bundle: "remoteEntry.js",
52439
- hosts: ["device-tab", "dashboard"],
52440
- requires: {
52441
- deviceContext: true,
52442
- integrationContext: false
52443
- },
52444
- defaultSize: "md",
52445
- allowedSizes: [
52446
- "sm",
52447
- "md",
52448
- "lg"
52449
- ],
52450
- defaultColumns: 6,
52451
- defaultRows: 1
52452
- }, {
52453
- tab: "device-tab",
52454
- label: "Zone Editor",
52455
- preAuth: false,
52456
- kind: "remote",
52457
- remote: {
52458
- remoteName: "addon_pipeline_orchestrator_widgets",
52459
- exposedModule: "./widgets",
52460
- componentKey: "zone-editor"
52461
- },
52462
- stableId: "zone-editor",
52463
- description: "Polygon / tripwire CRUD + per-stage rule editor.",
52464
- icon: "shapes",
52465
- bundle: "remoteEntry.js",
52466
- hosts: ["device-tab"],
52467
- requires: {
52468
- deviceContext: true,
52469
- integrationContext: false
52470
- },
52471
- defaultSize: "xl",
52472
- allowedSizes: ["lg", "xl"],
52473
- defaultColumns: 12,
52474
- defaultRows: 4
52475
- }];
52476
- //#endregion
52477
52743
  //#region src/settings-ui-schemas.ts
52478
52744
  /** Build the addon-level schema sections (cluster roles + crop + balancer + failover). */
52479
52745
  function buildGlobalSettingsSections(options) {
@@ -52838,6 +53104,22 @@ function buildDeviceSettingsSections(nodeOptions) {
52838
53104
  field: "detectionMode",
52839
53105
  notEquals: "disabled"
52840
53106
  }
53107
+ },
53108
+ {
53109
+ key: "activityDetectionFps",
53110
+ type: "slider",
53111
+ label: "Detection FPS while the device is working",
53112
+ min: 1,
53113
+ max: 10,
53114
+ step: 1,
53115
+ default: 1,
53116
+ showValue: true,
53117
+ unit: "fps",
53118
+ description: "A device-activity session lasts as long as the job does — a cleaning run is tens of minutes. This caps the rate for that session only; a session opened by motion keeps the rate above.",
53119
+ showWhen: {
53120
+ field: "motionSources",
53121
+ includes: "device-activity"
53122
+ }
52841
53123
  }
52842
53124
  ]
52843
53125
  },
@@ -52927,6 +53209,99 @@ function deriveRuntimeSettings(config) {
52927
53209
  };
52928
53210
  }
52929
53211
  //#endregion
53212
+ //#region src/viewer-ui-provider.ts
53213
+ /**
53214
+ * viewer-ui provider — the pipeline-orchestrator serves the CamStack viewer web
53215
+ * SPA (mirrors what the now-removed standalone addon-viewer-ui used to do).
53216
+ *
53217
+ * Why here: the orchestrator is a hub bootstrap addon (always installed + baked),
53218
+ * so folding the viewer serving into it avoids a second addon whose only job was
53219
+ * to hold static files. The viewer's Expo web export is COPIED into this addon's
53220
+ * `assets/viewer/` locally by the viewer repo's `scripts/copy-dist-to-orchestrator.js`
53221
+ * (run from a checkout that HAS the `camstack/` submodule + Expo toolchain — the
53222
+ * publish/image CI does not, which is exactly why we copy a pre-built dist rather
53223
+ * than build it here). vite emits only into `dist/`, so `assets/viewer` survives
53224
+ * the addon build; `assets` is in the package `files`, so it ships on
53225
+ * `camstack deploy`. The hub's `main.ts` resolves the `viewer-ui` singleton at
53226
+ * boot and mounts the SPA at `/viewer/camstack`.
53227
+ *
53228
+ * `index.js` runs from `dist/`, so the SPA root is one level up + `assets/viewer`.
53229
+ */
53230
+ var __dirname$1 = node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
53231
+ /** Absolute path to the staged viewer web SPA (`<addon-root>/assets/viewer`). */
53232
+ function resolveViewerDistDir() {
53233
+ return node_path.default.resolve(__dirname$1, "..", "assets", "viewer");
53234
+ }
53235
+ /** Version of the staged viewer, written by the copy script; 'unknown' if absent. */
53236
+ function readViewerVersion() {
53237
+ try {
53238
+ const raw = node_fs.default.readFileSync(node_path.default.join(resolveViewerDistDir(), ".viewer-version"), "utf-8").trim();
53239
+ if (raw) return raw;
53240
+ } catch {}
53241
+ return "unknown";
53242
+ }
53243
+ /** Build the viewer-ui provider. Serves whatever dist was staged into
53244
+ * `assets/viewer`; when nothing is staged the hub's mount cleanly 404s. */
53245
+ function createViewerUiProvider() {
53246
+ return {
53247
+ getStaticDir: async () => ({ staticDir: resolveViewerDistDir() }),
53248
+ getVersion: async () => ({ version: readViewerVersion() })
53249
+ };
53250
+ }
53251
+ //#endregion
53252
+ //#region src/widget-catalog.ts
53253
+ var pipelineOrchestratorWidgets = [{
53254
+ tab: "device-tab",
53255
+ label: "Pipeline Quick Stats",
53256
+ preAuth: false,
53257
+ kind: "remote",
53258
+ remote: {
53259
+ remoteName: "addon_pipeline_orchestrator_widgets",
53260
+ exposedModule: "./widgets",
53261
+ componentKey: "pipeline-quick-stats"
53262
+ },
53263
+ stableId: "pipeline-quick-stats",
53264
+ description: "Phase / Detection FPS / Inference / Active Tracks tile row.",
53265
+ icon: "activity",
53266
+ bundle: "remoteEntry.js",
53267
+ hosts: ["device-tab", "dashboard"],
53268
+ requires: {
53269
+ deviceContext: true,
53270
+ integrationContext: false
53271
+ },
53272
+ defaultSize: "md",
53273
+ allowedSizes: [
53274
+ "sm",
53275
+ "md",
53276
+ "lg"
53277
+ ],
53278
+ defaultColumns: 6,
53279
+ defaultRows: 1
53280
+ }, {
53281
+ tab: "device-tab",
53282
+ label: "Zone Editor",
53283
+ preAuth: false,
53284
+ kind: "remote",
53285
+ remote: {
53286
+ remoteName: "addon_pipeline_orchestrator_widgets",
53287
+ exposedModule: "./widgets",
53288
+ componentKey: "zone-editor"
53289
+ },
53290
+ stableId: "zone-editor",
53291
+ description: "Polygon / tripwire CRUD + per-stage rule editor.",
53292
+ icon: "shapes",
53293
+ bundle: "remoteEntry.js",
53294
+ hosts: ["device-tab"],
53295
+ requires: {
53296
+ deviceContext: true,
53297
+ integrationContext: false
53298
+ },
53299
+ defaultSize: "xl",
53300
+ allowedSizes: ["lg", "xl"],
53301
+ defaultColumns: 12,
53302
+ defaultRows: 4
53303
+ }];
53304
+ //#endregion
52930
53305
  //#region src/index.ts
52931
53306
  /**
52932
53307
  * addon-pipeline-orchestrator — hub-side camera-to-agent load balancer.
@@ -53241,6 +53616,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
53241
53616
  isCapActiveForDevice: (deviceId, capName) => this.isCapActiveForDevice(deviceId, capName),
53242
53617
  isAudioAnalysisActive: (deviceId) => this.isAudioAnalysisActive(deviceId),
53243
53618
  deviceHasOnboardMotionCap: (deviceId) => this.deviceHasOnboardMotionCap(deviceId),
53619
+ deviceHasActivitySignalCap: (deviceId) => this.deviceHasActivitySignalCap(deviceId),
53244
53620
  deviceHasNativeObjectDetectionCap: (deviceId) => this.deviceHasNativeObjectDetectionCap(deviceId),
53245
53621
  handleDeviceRegistered: (deviceId) => this.handleDeviceRegistered(deviceId),
53246
53622
  handleDeviceUnregistered: (deviceId) => this.handleDeviceUnregistered(deviceId),
@@ -54427,6 +54803,26 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
54427
54803
  * we err toward the analyzer (never lose detection coverage when the
54428
54804
  * binding lookup hiccups).
54429
54805
  */
54806
+ /**
54807
+ * True when the device's driver registered `recording-signal` — the cap a
54808
+ * device uses to say, itself, that it is working (a robot vacuum cleaning).
54809
+ * Drives the `device-activity` entry in the `motionSources` DEFAULT (D392),
54810
+ * so the source arrives on exactly the devices that can raise it and on no
54811
+ * other camera in the fleet.
54812
+ *
54813
+ * Same failure discipline as `deviceHasOnboardMotionCap`: a binding lookup
54814
+ * that throws answers `false`, which loses the DEFAULT and never the
54815
+ * operator's explicit choice (this is asked only when they pinned nothing).
54816
+ */
54817
+ async deviceHasActivitySignalCap(deviceId) {
54818
+ const api = this.api;
54819
+ if (!api) return false;
54820
+ try {
54821
+ return (await api.deviceManager.getBindings.query({ deviceId })).entries.some((e) => e.kind === "native" && e.capName === "recording-signal");
54822
+ } catch {
54823
+ return false;
54824
+ }
54825
+ }
54430
54826
  async deviceHasOnboardMotionCap(deviceId) {
54431
54827
  const api = this.api;
54432
54828
  if (!api) return false;