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