@camstack/addon-export-alexa 1.2.11 → 1.2.12

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.
@@ -26,604 +26,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  //#endregion
27
27
  let node_crypto = require("node:crypto");
28
28
  node_crypto = __toESM(node_crypto);
29
- //#region ../types/dist/event-category-41fKf-q9.mjs
30
- var EventCategory = /* @__PURE__ */ function(EventCategory) {
31
- EventCategory["SystemBoot"] = "system.boot";
32
- EventCategory["SystemAddonsReady"] = "system.addons-ready";
33
- EventCategory["SystemRestarting"] = "system.restarting";
34
- /**
35
- * Fired exactly once after the hub finishes booting following a
36
- * restart that was triggered by `RestartCoordinator` (today: framework
37
- * live-update). Payload is the marker that was on disk at boot
38
- * (`PendingRestartMarkerPayload`); admin UI listens for it to display a
39
- * success toast describing what changed.
40
- *
41
- * Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
42
- */
43
- EventCategory["SystemRestartCompleted"] = "system.restart-completed";
44
- /**
45
- * Readiness transition for a capability provider. Every producer emits
46
- * this event on `onInitialize` completion, `onDestroy`, and
47
- * `$node.reconnect`; every consumer that needs to gate on a cross-process
48
- * cap subscribes via the kernel's readiness module (`awaitReady` /
49
- * `onReadyState`) instead of polling. Payload is
50
- * `SystemReadyStatePayload` — see event-bus.ts.
51
- */
52
- EventCategory["SystemReadyState"] = "system.ready-state";
53
- EventCategory["AddonStarted"] = "addon.started";
54
- EventCategory["AddonStopped"] = "addon.stopped";
55
- EventCategory["AddonRestarted"] = "addon.restarted";
56
- EventCategory["AddonUpdated"] = "addon.updated";
57
- EventCategory["AddonInstalled"] = "addon.installed";
58
- EventCategory["AddonUninstalled"] = "addon.uninstalled";
59
- EventCategory["AddonCrashed"] = "addon.crashed";
60
- EventCategory["AddonError"] = "addon.error";
61
- EventCategory["AddonPageReady"] = "addon.page-ready";
62
- EventCategory["AddonWidgetReady"] = "addon.widget-ready";
63
- /**
64
- * Addon failed to load (import or initialize). Emitted by the kernel's
65
- * AddonHealthMonitor only AFTER the boot grace period ends — failures
66
- * during the first 5 minutes are silently retried without alerting
67
- * (slow-starting addons must have time to come up). Post-grace, this
68
- * event is emitted exactly once per failure-streak; AlertCenter
69
- * consumes it to create a persistent operator-visible alert.
70
- *
71
- * Payload: `{ packageName, addonId?, error: { message, stack }, retryCount, nextRetryAt }`.
72
- */
73
- EventCategory["AddonLoadFailed"] = "addon.load-failed";
74
- /**
75
- * Addon recovered from a previous failure. Emitted when an addon
76
- * transitions from `failed` back to `healthy` (typically via the
77
- * monitor's auto-retry loop, or after manual `addons.retryLoad`).
78
- * AlertCenter dismisses the corresponding `AddonLoadFailed` alert
79
- * on this event.
80
- */
81
- EventCategory["AddonLoadRecovered"] = "addon.load-recovered";
82
- /**
83
- * Monitor scheduled the next retry for a failed addon. Transient —
84
- * surfaced to the UI for live-updating the "next retry in Ns"
85
- * countdown on the Addons page row, NOT persisted as an alert.
86
- */
87
- EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
88
- /**
89
- * A liveness invariant of this node is false — it has no devices, every
90
- * camera is offline, or a camera that was recording has stopped producing
91
- * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
92
- * `findingId` is stable across ticks), only after its boot grace period.
93
- * AlertCenter raises a persistent operator-visible alert.
94
- *
95
- * It exists because on 2026-08-03 the hub ran four hours with zero devices
96
- * and zero recordings while every surface stayed quiet.
97
- *
98
- * Payload: `{ findingId, severity, title, message, deviceId? }`.
99
- */
100
- EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
101
- /**
102
- * A previously reported liveness fault is true again. AlertCenter dismisses
103
- * the matching `SystemLivenessFailed` alert.
104
- *
105
- * Payload: `{ findingId }`.
106
- */
107
- EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
108
- /**
109
- * Monitor is attempting to reload a failed addon NOW. UI uses this
110
- * to show a spinner during the retry attempt. Same transient nature
111
- * as AddonRetryScheduled.
112
- */
113
- EventCategory["AddonRetryAttempting"] = "addon.retry-attempting";
114
- EventCategory["DeviceRegistered"] = "device.registered";
115
- EventCategory["DeviceUnregistered"] = "device.unregistered";
116
- EventCategory["DeviceEnabled"] = "device.enabled";
117
- EventCategory["DeviceDisabled"] = "device.disabled";
118
- EventCategory["DeviceSettingsUpdated"] = "device.settings-updated";
119
- /**
120
- * Emitted when the set of native capability providers bound to a device
121
- * changes — e.g. an addon registers a new native cap via
122
- * `DeviceContext.registerNativeCap`, or all native bindings for a device
123
- * are cleared on removal. Hub consumers re-resolve device-proxy routes
124
- * when this fires.
125
- */
126
- EventCategory["DeviceBindingsChanged"] = "device.bindings-changed";
127
- /**
128
- * Emitted when the operator-organisational meta surface changes
129
- * (`name` / `location` / `disabled`). Payload: `{deviceId, field,
130
- * value}`. Live consumers (UI device list, alert center) react
131
- * without polling. Distinct from `DeviceSettingsUpdated` which
132
- * fires on hardware-config changes (host/port/credentials/etc).
133
- */
134
- EventCategory["DeviceMetaChanged"] = "device.meta-changed";
135
- /**
136
- * Emitted by `BaseDevice.updateSourceInfo()` after a successful patch
137
- * to the device's upstream-system identity / rendering envelope. The
138
- * full new SourceInfo travels in the payload so cross-process listeners
139
- * (UI, export adapters) don't need to re-read the meta blob.
140
- *
141
- * Payload: `{deviceId, sourceInfo}`.
142
- *
143
- * Distinct from `DeviceMetaChanged` which covers the operator-edited
144
- * surface (`name` / `location` / `disabled`). The two share the
145
- * persistence layer (both ride on `device-manager.setMetadata` for
146
- * SourceInfo, or `setName`/`setLocation`/`setDisabled` for the meta
147
- * fields) but consumers care about different subsets.
148
- */
149
- EventCategory["DeviceSourceInfoChanged"] = "device.source-info-changed";
150
- /**
151
- * Emitted by DeviceStreamWiringService after a successful
152
- * `stream-broker.registerDeviceStreams` call. Payload includes
153
- * deviceId — consumers look up the full registered device info via
154
- * `brokerManager.getRegisteredDevice(deviceId)`.
155
- *
156
- * Replaces the legacy `StreamRouterService.onDeviceRegistered` callback.
157
- */
158
- EventCategory["DeviceStreamsRegistered"] = "device.streams-registered";
159
- /**
160
- * Device-level "fully provisioned" signal — the EXPORT trigger. Emitted once
161
- * a device's persisted export-relevant shape (its `DeviceFeature` set) is
162
- * established or changes, carrying `{deviceId, fingerprint, generation}`.
163
- * Export adapters (Alexa / HAP) react to THIS instead of the chatty per-cap
164
- * `DeviceBindingsChanged`, turning a boot's incomplete→complete trickle into
165
- * one clean delta. `fingerprint` is `canonicalDeviceFingerprint(shape)`.
166
- *
167
- * Spec: docs/superpowers/specs/2026-06-01-alexa-hap-export-reconciler-design.md
168
- */
169
- EventCategory["DeviceProvisioned"] = "device.provisioned";
170
- /**
171
- * Fires once when a device's initial feature-probe completes successfully
172
- * (lastProbedAt 0→>0); exported shape is now stable. Telemetry (may be
173
- * dropped) — consumers MUST also gate on the device record's `probed` flag.
174
- */
175
- EventCategory["DeviceReady"] = "device.ready";
176
- EventCategory["IntegrationEnabled"] = "integration.enabled";
177
- EventCategory["IntegrationDisabled"] = "integration.disabled";
178
- EventCategory["IntegrationDeleted"] = "integration.deleted";
179
- /** Emitted when a broker connection's status flips (connected /
180
- * disconnected / error). Carries `{ brokerId, status, error? }`. */
181
- EventCategory["BrokerStatusChanged"] = "broker.status-changed";
182
- /** Emitted for every subscription-matched message routed by a
183
- * broker provider. Carries `{ brokerId, subscriptionId, key, payload }`.
184
- * Consumers filter by `brokerId` + `subscriptionId` in the handler. */
185
- EventCategory["BrokerMessage"] = "broker.message";
186
- EventCategory["ProviderStarted"] = "provider.started";
187
- EventCategory["ProviderStopped"] = "provider.stopped";
188
- EventCategory["ProcessCrashed"] = "process.crashed";
189
- EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
190
- EventCategory["ProcessRestarted"] = "process.restarted";
191
- EventCategory["RecordingStarted"] = "recording.started";
192
- EventCategory["RecordingStopped"] = "recording.stopped";
193
- EventCategory["RecordingError"] = "recording.error";
194
- EventCategory["RecordingHealthDegraded"] = "recording.health.degraded";
195
- EventCategory["RecordingStorageCritical"] = "recording.storage.critical";
196
- EventCategory["RecordingSegmentWritten"] = "recording.segment.written";
197
- EventCategory["RecordingPolicyFallback"] = "recording.policy.fallback";
198
- EventCategory["RecordingRetentionCompleted"] = "recording.retention.completed";
199
- /** Export render progress (0–100). Telemetry (D8): a lost tick is a stale
200
- * progress bar the client reconciles via `recordingExport.getExport`. */
201
- EventCategory["RecordingExportProgress"] = "recording.export.progress";
202
- EventCategory["RecordingExportCompleted"] = "recording.export.completed";
203
- EventCategory["RecordingExportFailed"] = "recording.export.failed";
204
- EventCategory["DetectionEvent"] = "detection.event";
205
- EventCategory["SessionTrackNew"] = "session.track.new";
206
- EventCategory["SessionTrackExpired"] = "session.track.expired";
207
- EventCategory["BenchmarkProgress"] = "benchmark.progress";
208
- EventCategory["PlatformProbePhase"] = "platform-probe.phase";
209
- EventCategory["PipelineProgress"] = "pipeline.progress";
210
- /** Per-frame execution trace emitted by the pipeline executor for live observability. */
211
- EventCategory["PipelineTrace"] = "pipeline.trace";
212
- /**
213
- * Raw inference output emitted by `addon-pipeline-runner` after running the
214
- * detection pipeline on a frame. Carries the `FrameResult` (with
215
- * `detections[]`, `width`/`height`, timing debug) — never the frame
216
- * buffer itself. Hub-side consumers (analysis pipeline, class filters,
217
- * notifications) subscribe to this and re-emit `detection.result` after
218
- * post-processing.
219
- */
220
- EventCategory["PipelineInferenceResult"] = "pipeline.inference-result";
221
- /**
222
- * Camera lifecycle event emitted by `addon-pipeline-orchestrator` when it
223
- * assigns or unassigns a camera to/from an agent. Carries no frame data;
224
- * pure observability for UI dashboards and metrics consumers.
225
- */
226
- EventCategory["PipelineCameraAssigned"] = "pipeline.camera-assigned";
227
- EventCategory["PipelineCameraUnassigned"] = "pipeline.camera-unassigned";
228
- /**
229
- * Per-camera pipeline config was mutated by the orchestrator
230
- * (3-level settings change via `setAgentAddonDefaults` /
231
- * `setCameraStepToggle` / `setCameraPipelineForAgent` or a
232
- * pipeline-scoped `applyDeviceSettingsPatch`). Orchestrator
233
- * subscribes to its own emission to hot-reload the assigned runner
234
- * via `attachCamera` so the next frame executes against the new
235
- * engine/steps without waiting for a rebalance or the next
236
- * `DeviceStreamsRegistered` cycle.
237
- */
238
- EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
239
- /**
240
- * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
241
- * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
242
- * capabilities — at boot, on agent online/offline, and on an ingest-node
243
- * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
244
- * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
245
- * cross-process `getIngestOwner` query (push the authority's decision instead
246
- * of polling it on the hot path). Idempotent state — re-emitted on every
247
- * topology change, so a dropped event self-heals on the next one (plus the
248
- * broker's long backstop reconcile query).
249
- */
250
- EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
251
- /**
252
- * Periodic snapshot of per-node pipeline-runner load
253
- * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
254
- * subscribe instead of polling `pipelineRunner.getLocalLoad`.
255
- * `nodeId` carried in the payload + on `event.source.nodeId`.
256
- */
257
- EventCategory["PipelineRunnerLoadSnapshot"] = "pipeline.runner-load-snapshot";
258
- /**
259
- * Periodic snapshot of per-camera pipeline metrics (`CameraMetrics`
260
- * + `deviceId` + `nodeId`). Emitted ~1Hz by every runner for each
261
- * attached camera. UI subscribes to drive overlay phase / fps /
262
- * inference time without polling `getCameraMetrics`.
263
- */
264
- EventCategory["PipelineCameraMetricsSnapshot"] = "pipeline.camera-metrics-snapshot";
265
- /**
266
- * Periodic snapshot of stream-broker per-broker statistics (input
267
- * fps, decoded fps, bitrate, codec). Emitted ~1Hz by every
268
- * stream-broker process for each active broker so the UI can drive
269
- * the Stream / Cluster dashboards without polling
270
- * `streamBroker.listAllProfileSlots` and friends.
271
- */
272
- EventCategory["StreamBrokerMetricsSnapshot"] = "stream-broker.metrics-snapshot";
273
- /**
274
- * Cap event fired by `stream-broker` when a profile slot enters
275
- * "demanded" state — a cam stream has been assigned and at least one
276
- * consumer (RTSP restream, decoded subscriber, WebRTC session, …) is
277
- * present. Camera-provider addons (Reolink Baichuan push, …)
278
- * subscribe to this category to start their underlying transport
279
- * lazily. Payload: `{ deviceId, camStreamId, profile }`.
280
- */
281
- EventCategory["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
282
- /**
283
- * Cap event fired by `stream-broker` when the last consumer leaves a
284
- * previously-demanded cam stream. Providers tear down their
285
- * underlying transport on receipt. Payload: `{ deviceId, camStreamId }`.
286
- */
287
- EventCategory["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
288
- /**
289
- * Cap event fired by `stream-broker` when a broker fails to dial a
290
- * managed-loopback source (today: `pull-rfc4571`) and the publisher
291
- * needs to refresh the cached URL. The lib's TCP server idle-tears-down
292
- * on its own schedule, so a re-publish with a fresh `host:port` is the
293
- * only way to keep the broker dialable. Camera providers (Reolink
294
- * Baichuan native, …) subscribe and respond by re-running their publish
295
- * pipeline.
296
- * Payload: `{ deviceId, camStreamId, brokerId }`.
297
- */
298
- EventCategory["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
299
- /**
300
- * A camera provider changed a device's stream parameters (codec /
301
- * resolution / bitrate / a stream added or removed). A LOW-LATENCY NUDGE
302
- * for the stream-broker's catalog reconcile: it carries NO authoritative
303
- * data — the broker re-PULLS that device's `stream-catalog` cap on receipt
304
- * (the 30s reconcile poll is the backstop if this nudge is dropped). Emitted
305
- * by providers from `stream-params.setProfile`. Payload: `{ deviceId }`.
306
- */
307
- EventCategory["StreamParamsChanged"] = "stream-params.changed";
308
- /**
309
- * Generic per-device runtime-state change. Fired by `device-manager`
310
- * whenever a persisted slice in any cap's `runtimeState` shape
311
- * mutates. Payload: `{deviceId, capName, slice}`. Subscribers are
312
- * the `deviceState` cap router (cross-process listeners) and the
313
- * deviceProxy reactive bindings (`device.state.<capName>.value`).
314
- * Cap-specific events (`battery.onStatusChanged`, …) still fire
315
- * — they're authoritative for callers that want a typed payload
316
- * without filtering on `capName`.
317
- */
318
- EventCategory["DeviceStateChanged"] = "device.state-changed";
319
- /**
320
- * Frame occupancy for a camera CHANGED — a tracked object was gained or
321
- * lost. Carries `{ deviceId, totalObjects, byClass, zones }`.
322
- *
323
- * Emitted only on a change, so a steady scene is silent. It exists so a
324
- * client can stop polling `zoneAnalytics.getCurrentSnapshot`: that was the
325
- * one live badge with no push signal at all, and it cost a request every
326
- * four seconds per visible camera.
327
- *
328
- * Like every event it is telemetry and may be dropped ([D8]) — a consumer
329
- * keeps a slow reconcile rather than trusting it alone.
330
- */
331
- EventCategory["ZoneAnalyticsOccupancyChanged"] = "zone-analytics.occupancy-changed";
332
- /**
333
- * Cap event fired by every device that registers the `battery`
334
- * capability. Mirrors the cap definition's `onStatusChanged`. Carries
335
- * `{ deviceId, status: BatteryStatus }`. Subscribers (alert center,
336
- * snapshot wrapper, UI) react to charge/sleep transitions without
337
- * polling `batteryCapability.getStatus`.
338
- */
339
- EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
340
- /**
341
- * Emitted by the battery cap provider WHEN `wakeForStream` enters the
342
- * "wake in progress" window — between the Baichuan wake-up issue and
343
- * the camera's first dialed-back RTP packet. The stream-broker
344
- * manager subscribes to flip the per-broker placeholder reason to
345
- * `'waking'` so viewers see a labelled "WAKING UP" tile instead of
346
- * the generic `'reconnecting'` frame. Carries `{ deviceId }`. The
347
- * complementary "wake complete" signal is the existing
348
- * `BatteryOnStatusChanged { sleeping: false }` event.
349
- */
350
- EventCategory["BatteryOnWakeStarted"] = "battery.onWakeStarted";
351
- /**
352
- * Cap event fired by every device that registers the `doorbell`
353
- * capability. Mirrors `doorbellCapability.events.onPressed`. Carries
354
- * `{ deviceId, timestamp }`. Operators consuming the UI subscribe
355
- * here to render transient ring toasts and a "Recent presses" row
356
- * on the device detail page.
357
- */
358
- EventCategory["DoorbellOnPressed"] = "doorbell.onPressed";
359
- /**
360
- * Cap event fired by every device that registers the `event-emitter`
361
- * capability. Mirrors `eventEmitterCapability.events.onEvent`. Carries
362
- * `{ deviceId, eventType, data, timestamp, seq }` — the device's EXACT
363
- * declared event verbatim (NO normalization). Subscribers (UI event
364
- * stream, advanced-notifier rules) react to fired events without
365
- * holding a cap reference.
366
- */
367
- EventCategory["EventEmitted"] = "event-emitter.event";
368
- /**
369
- * Periodic snapshot of the per-node detection-pipeline engine
370
- * registry (loaded engines, models resident, in-use cameras, idle
371
- * TTL). Emitted ~0.2Hz (every 5 s) by every detection-pipeline
372
- * process. The Engines tab subscribes to drive its inventory view
373
- * without polling `pipelineExecutor.listLoadedEngines`.
374
- */
375
- EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
376
- /**
377
- * Per-node detection-engine runtime-provisioning transition. Emitted by
378
- * the detection-pipeline provider on every state change of its lazy
379
- * engine-provisioning machine (idle → installing → verifying → ready,
380
- * or → failed with a `nextRetryAt`). Payload is the
381
- * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
382
- * node. The Pipeline page subscribes to drive a live "installing
383
- * OpenVINO… / ready" indicator per node without polling
384
- * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
385
- * also reads the cap snapshot on mount / reconnect. Phase 2.
386
- */
387
- EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
388
- /**
389
- * Cluster topology snapshot. Carries the same payload returned by
390
- * `nodes.topology` (every reachable node + addons + processes).
391
- * Emitted by the hub on any agent / addon lifecycle change
392
- * (debounced) plus a periodic safety net. Replaces UI polling on
393
- * `nodes.topology` — admin-ui dashboards subscribe to drive the
394
- * cluster view directly from the event payload.
395
- */
396
- EventCategory["ClusterTopologySnapshot"] = "cluster.topology-snapshot";
397
- /**
398
- * Periodic per-node system metrics snapshot (CPU / memory / GPU /
399
- * disk / network). Emitted ~0.2 Hz by the metrics-provider addon
400
- * for each node. Drives the dashboard SystemStatus / ProcessResources
401
- * widgets without polling `metricsProvider.getCurrent`.
402
- */
403
- EventCategory["MetricsNodeResourcesSnapshot"] = "metrics.node-resources-snapshot";
404
- /**
405
- * Periodic per-node process-tree snapshot (camstack-related pids
406
- * with ghost / managed / root classification). Emitted ~0.2 Hz by
407
- * the metrics-provider addon. Drives the Cluster → Processes tab
408
- * without polling `metricsProvider.listNodeProcesses`.
409
- */
410
- EventCategory["MetricsNodeProcessesSnapshot"] = "metrics.node-processes-snapshot";
411
- /**
412
- * Capability binding change event emitted by `addon-pipeline-orchestrator`
413
- * when a user changes which addon implements a cap on a node. Subscribed
414
- * by every kernel process to update its local `preferredProviderRegistry`
415
- * so future capability lookups respect the new binding.
416
- */
417
- /**
418
- * A capability binding was changed for a node — addon X now provides
419
- * capability `cap` on node `nodeId`. Lives under the generic
420
- * `capability.*` namespace because capability bindings are a kernel-
421
- * level concept used by many addons, not strictly pipeline-scoped.
422
- */
423
- EventCategory["CapabilityBindingChanged"] = "capability.binding-changed";
424
- EventCategory["ModelDownloadProgress"] = "model.download.progress";
425
- EventCategory["AgentRegistered"] = "agent.registered";
426
- EventCategory["AgentUnregistered"] = "agent.unregistered";
427
- EventCategory["AgentOnline"] = "agent.online";
428
- EventCategory["AgentOffline"] = "agent.offline";
429
- /** Forked worker process (e.g. hub/pipeline) connected to the broker. */
430
- EventCategory["WorkerOnline"] = "worker.online";
431
- /** Forked worker process disconnected from the broker. */
432
- EventCategory["WorkerOffline"] = "worker.offline";
433
- EventCategory["AgentTaskDispatched"] = "agent.task.dispatched";
434
- EventCategory["AgentTaskAssigned"] = "agent.task.assigned";
435
- EventCategory["AgentTrpcConnected"] = "agent.trpc.connected";
436
- EventCategory["AgentWsConnected"] = "agent.ws.connected";
437
- EventCategory["AgentWsDisconnected"] = "agent.ws.disconnected";
438
- EventCategory["AgentBackupActivated"] = "agent.backup.activated";
439
- EventCategory["OrchestrationSettingsUpdated"] = "orchestration.settings-updated";
440
- /**
441
- * Per-agent hwaccel preference changed (user override set, cleared, or
442
- * re-probed). Observability event — decoders pull the current pref at
443
- * `createSession`, so running sessions keep their current backend until
444
- * they rotate naturally (camera add/remove, stream restart). Future
445
- * work can wire a listener in stream-broker that force-rotates live
446
- * sessions; for now this event powers logs + admin-UI toast feedback.
447
- */
448
- EventCategory["PipelineAgentHwaccelChanged"] = "pipeline.agent-hwaccel-changed";
449
- EventCategory["MotionAnalysis"] = "detection.motion-analysis";
450
- /** All raw motion zones from CCL before minArea filter — for UI debug overlay. */
451
- EventCategory["MotionZonesRaw"] = "detection.motion-zones-raw";
452
- /**
453
- * Per-camera motion phase transition (`watching ↔ active`) emitted
454
- * by the runner. Mirrors the `motion.onMotionChanged` cap event
455
- * surface — payload `MotionOnMotionChangedPayload` carries
456
- * `{deviceId, detected, timestamp, source, regions?}`. Subscribers
457
- * include addons that need to react to motion state without
458
- * polling the runtime-state mirror.
459
- */
460
- EventCategory["MotionOnMotionChanged"] = "motion.on-motion-changed";
461
- EventCategory["DetectionResult"] = "detection.result";
462
- EventCategory["DetectionRaw"] = "detection.raw";
463
- EventCategory["DetectionCameraNative"] = "detection.camera-native";
464
- /**
465
- * Canonical per-chunk live audio pipeline output. Payload is
466
- * `PipelineAudioInferenceResultPayload` carrying a full `AudioResult`
467
- * (level + detections + debug). Lives on the pipeline.* namespace
468
- * alongside `pipeline.inference-result` (video) for symmetry.
469
- */
470
- EventCategory["PipelineAudioInferenceResult"] = "pipeline.audio-inference-result";
471
- EventCategory["DetectionPhaseTransition"] = "detection.phase-transition";
472
- EventCategory["ProviderMotion"] = "provider.motion";
473
- EventCategory["ProviderDetection"] = "provider.detection";
474
- EventCategory["EnrichmentEmbeddingStored"] = "enrichment.embedding.stored";
475
- EventCategory["EnrichmentSceneStateChanged"] = "enrichment.scene.state-changed";
476
- EventCategory["EnrichmentActivitySummary"] = "enrichment.activity.summary";
477
- /**
478
- * Unified track-lifecycle event: ONE category carrying `phase:
479
- * 'start' | 'update' | 'end'` with a rich, typed
480
- * `PipelineAnalyticsTrackLifecyclePayload`. Supersedes the thin
481
- * `PipelineAnalyticsTrackStarted` / `PipelineAnalyticsTrackEnded`
482
- * pair — a consumer subscribes once and branches on `phase`.
483
- */
484
- EventCategory["PipelineAnalyticsTrackLifecycle"] = "pipeline-analytics.track-lifecycle";
485
- /**
486
- * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
487
- * (`phase:'start'`). Kept as a soft alias — no known subscribers.
488
- */
489
- EventCategory["PipelineAnalyticsTrackStarted"] = "pipeline-analytics.track-started";
490
- /**
491
- * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
492
- * (`phase:'end'`). Kept as a soft alias — no known subscribers.
493
- */
494
- EventCategory["PipelineAnalyticsTrackEnded"] = "pipeline-analytics.track-ended";
495
- EventCategory["PipelineAnalyticsDetectionEvent"] = "pipeline-analytics.detection-event";
496
- EventCategory["PipelineAnalyticsFrameTracked"] = "pipeline-analytics.frame-tracked";
497
- /**
498
- * Fired by `addon-post-analysis` when a parked (stationary) object appears
499
- * (a track was promoted to a stationary-registry entry) or departs (the
500
- * object moved / was removed). Telemetry (D8): lossy, drives a UI refresh of
501
- * the dedicated "Stationary" section — never the live event feed. Payload:
502
- * `{ deviceId, entryId, className, phase:'appeared'|'departed', timestamp }`.
503
- */
504
- EventCategory["PipelineAnalyticsStationaryChanged"] = "pipeline-analytics.stationary-changed";
505
- /**
506
- * Fired by `addon-post-analysis` when a package-drop is confirmed inside
507
- * a package zone — a newly-appeared stationary object of a package class
508
- * that cleared the class / zone / dwell / size gates. Payload:
509
- * `PipelineAnalyticsPackageDeliveredPayload` carrying `{ deviceId,
510
- * entryId, className, zoneIds, keyFrameMediaKey?, bbox, timestamp }`.
511
- * Telemetry (D8): the durable record is the `package-events` store row;
512
- * this bus topic drives notifier rules + live UI. See
513
- * docs/superpowers/specs/2026-07-17-package-zones-design.md §5.1.
514
- */
515
- EventCategory["PipelineAnalyticsPackageDelivered"] = "pipeline-analytics.package-delivered";
516
- /**
517
- * Fired by `addon-post-analysis` when a previously-delivered package
518
- * leaves its zone (the stationary entry departed — moved or swept).
519
- * Payload: `PipelineAnalyticsPackagePickedUpPayload` carrying
520
- * `{ deviceId, entryId, deliveredEventId, className, timestamp }`.
521
- * Telemetry (D8). See package-zones-design §5.2.
522
- */
523
- EventCategory["PipelineAnalyticsPackagePickedUp"] = "pipeline-analytics.package-picked-up";
524
- /**
525
- * Fired by `addon-post-analysis` whenever a gallery face row changes:
526
- * `kind:'buffered'` a new detected face was persisted, `'assigned'` /
527
- * `'unassigned'` its identity link changed, `'deleted'` the row was
528
- * removed. Telemetry semantics (D8): a lost event only delays a refresh,
529
- * never a correctness issue. Payload `PipelineAnalyticsFaceGalleryChangedPayload`.
530
- */
531
- EventCategory["PipelineAnalyticsFaceGalleryChanged"] = "pipeline-analytics.face-gallery-changed";
532
- /**
533
- * Fired by `addon-post-analysis` whenever a gallery plate row changes:
534
- * `kind:'buffered'` a new read was persisted (`PlateRecognizer.onTrackEnd`),
535
- * `'assigned'` / `'unassigned'` its vehicle link changed
536
- * (`PlateGalleryProvider`), `'deleted'` the row was removed. Telemetry
537
- * semantics (D8): a lost event only delays a refresh, never a correctness
538
- * issue. Payload `PipelineAnalyticsPlateGalleryChangedPayload`.
539
- */
540
- EventCategory["PipelineAnalyticsPlateGalleryChanged"] = "pipeline-analytics.plate-gallery-changed";
541
- EventCategory["FrigateLiveEvent"] = "frigate.live-event";
542
- EventCategory["CameraStreamsProfileSlotsChanged"] = "camera-streams.onProfileSlotsChanged";
543
- /**
544
- * Stream-broker health watchdog. Per-broker (deviceId/profile) emission.
545
- * `stream.offline` fires after STREAM_STALE_TIMEOUT_MS without an encoded
546
- * packet on an active broker. `stream.online` fires on first packet
547
- * after a stale gap (or on initial first packet). Payload includes
548
- * the assigned camStreamId as `profileKey`.
549
- */
550
- EventCategory["StreamOnline"] = "stream.online";
551
- EventCategory["StreamOffline"] = "stream.offline";
552
- EventCategory["NetworkTunnelStarted"] = "network.tunnel.started";
553
- EventCategory["NetworkTunnelStopped"] = "network.tunnel.stopped";
554
- /** Fired by the `local-network` cap when the host's interface set
555
- * changes (new IP from DHCP, VPN connect, docker bridge added). */
556
- EventCategory["LocalNetworkChanged"] = "network.local.changed";
557
- /**
558
- * Fired by a `mesh-network` provider (Tailscale, …) when its
559
- * mesh-reachable host changes (join / leave / MagicDNS or 100.x IP
560
- * change). Lets `local-network` fold the mesh endpoint into its
561
- * connection-endpoint list without a cross-package import. Payload
562
- * carries the preferred host (`host: ''` = no longer reachable),
563
- * the hub port, and the scheme. Telemetry-grade (D8): consumers
564
- * pull-reconcile from the provider's `getStatus` on reconnect. */
565
- EventCategory["MeshNetworkChanged"] = "network.mesh.changed";
566
- EventCategory["BackupCompleted"] = "backup.completed";
567
- EventCategory["BackupRestored"] = "backup.restored";
568
- EventCategory["NotificationDispatched"] = "notification.dispatched";
569
- EventCategory["NotificationFailed"] = "notification.failed";
570
- EventCategory["DeviceUpdated"] = "device.updated";
571
- /**
572
- * Transport-level connectivity. Emitted by the device driver when
573
- * the underlying control socket actually connects / disconnects
574
- * (Baichuan TCP, ONVIF probe response, RTSP DESCRIBE, …) — NOT for
575
- * power-state transitions on a battery camera. For battery wake /
576
- * doze cycles see `DeviceAwake` / `DeviceSleeping`.
577
- */
578
- EventCategory["DeviceOnline"] = "device.online";
579
- /**
580
- * Stream-broker watchdog — emitted when no encoded packet has been
581
- * received for STREAM_STALE_TIMEOUT_MS on an active broker (rtsp or
582
- * push). Paired with DeviceOnline which fires on first packet after
583
- * a stale gap. Payload: DeviceStreamHealthPayload.
584
- */
585
- EventCategory["DeviceOffline"] = "device.offline";
586
- /**
587
- * Battery cam woke up — physical power-state transition reported
588
- * by the device firmware. Distinct from `DeviceOnline` so a UI panel
589
- * watching power state doesn't flap on every UDP socket reconnect.
590
- */
591
- EventCategory["DeviceAwake"] = "device.awake";
592
- /**
593
- * Battery cam went to sleep. See `DeviceAwake`.
594
- */
595
- EventCategory["DeviceSleeping"] = "device.sleeping";
596
- EventCategory["RetentionCleanup"] = "retention.cleanup";
597
- /**
598
- * Legacy bulk-update progress snapshot (payload `BulkUpdateState`). No longer
599
- * emitted — F3 removed the coordinator that produced it; "Update all" now runs
600
- * as one lifecycle engine job (`AddonsJobProgress`/`AddonsJobLog`). Retained
601
- * (with `BulkUpdateState`) only to avoid regenerating the event maps; removed
602
- * in F4 once live bulk progress is re-implemented over the engine events.
603
- */
604
- EventCategory["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
605
- EventCategory["AddonsJobProgress"] = "addons.job-progress";
606
- EventCategory["AddonsJobLog"] = "addons.job-log";
607
- /**
608
- * A container's child visibility toggled (hidden/shown). Emitted by the
609
- * `accessories` cap when a child device is hidden or revealed.
610
- * Payload: `{ deviceId, childDeviceId, hidden }`.
611
- */
612
- EventCategory["AccessoriesChildVisibilityChanged"] = "accessories.onChildVisibilityChanged";
613
- /**
614
- * A container's child set changed (children added/removed/reordered).
615
- * Payload: `{ deviceId, childDeviceIds, hiddenChildIds }`.
616
- */
617
- EventCategory["AccessoriesChanged"] = "accessories.onAccessoriesChanged";
618
- /**
619
- * Progress update from a running model conversion job.
620
- * Payload: `{ kind: 'model-convert', phase, sessionId?, pct?, detail? }`.
621
- * Emitted by `addon-model-studio` on the converting node.
622
- */
623
- EventCategory["ModelConvertProgress"] = "model-convert.progress";
624
- return EventCategory;
625
- }({});
626
- //#endregion
627
29
  //#region ../../node_modules/zod/v4/core/core.js
628
30
  var _a$1;
629
31
  function $constructor(name, initializer, params) {
@@ -5279,6 +4681,604 @@ var ZodIssueCode = {
5279
4681
  /** @deprecated Do not use. Stub definition, only included for zod-to-json-schema compatibility. */
5280
4682
  var ZodFirstPartyTypeKind;
5281
4683
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
4684
+ //#endregion
4685
+ //#region ../types/dist/event-category-41fKf-q9.mjs
4686
+ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4687
+ EventCategory["SystemBoot"] = "system.boot";
4688
+ EventCategory["SystemAddonsReady"] = "system.addons-ready";
4689
+ EventCategory["SystemRestarting"] = "system.restarting";
4690
+ /**
4691
+ * Fired exactly once after the hub finishes booting following a
4692
+ * restart that was triggered by `RestartCoordinator` (today: framework
4693
+ * live-update). Payload is the marker that was on disk at boot
4694
+ * (`PendingRestartMarkerPayload`); admin UI listens for it to display a
4695
+ * success toast describing what changed.
4696
+ *
4697
+ * Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
4698
+ */
4699
+ EventCategory["SystemRestartCompleted"] = "system.restart-completed";
4700
+ /**
4701
+ * Readiness transition for a capability provider. Every producer emits
4702
+ * this event on `onInitialize` completion, `onDestroy`, and
4703
+ * `$node.reconnect`; every consumer that needs to gate on a cross-process
4704
+ * cap subscribes via the kernel's readiness module (`awaitReady` /
4705
+ * `onReadyState`) instead of polling. Payload is
4706
+ * `SystemReadyStatePayload` — see event-bus.ts.
4707
+ */
4708
+ EventCategory["SystemReadyState"] = "system.ready-state";
4709
+ EventCategory["AddonStarted"] = "addon.started";
4710
+ EventCategory["AddonStopped"] = "addon.stopped";
4711
+ EventCategory["AddonRestarted"] = "addon.restarted";
4712
+ EventCategory["AddonUpdated"] = "addon.updated";
4713
+ EventCategory["AddonInstalled"] = "addon.installed";
4714
+ EventCategory["AddonUninstalled"] = "addon.uninstalled";
4715
+ EventCategory["AddonCrashed"] = "addon.crashed";
4716
+ EventCategory["AddonError"] = "addon.error";
4717
+ EventCategory["AddonPageReady"] = "addon.page-ready";
4718
+ EventCategory["AddonWidgetReady"] = "addon.widget-ready";
4719
+ /**
4720
+ * Addon failed to load (import or initialize). Emitted by the kernel's
4721
+ * AddonHealthMonitor only AFTER the boot grace period ends — failures
4722
+ * during the first 5 minutes are silently retried without alerting
4723
+ * (slow-starting addons must have time to come up). Post-grace, this
4724
+ * event is emitted exactly once per failure-streak; AlertCenter
4725
+ * consumes it to create a persistent operator-visible alert.
4726
+ *
4727
+ * Payload: `{ packageName, addonId?, error: { message, stack }, retryCount, nextRetryAt }`.
4728
+ */
4729
+ EventCategory["AddonLoadFailed"] = "addon.load-failed";
4730
+ /**
4731
+ * Addon recovered from a previous failure. Emitted when an addon
4732
+ * transitions from `failed` back to `healthy` (typically via the
4733
+ * monitor's auto-retry loop, or after manual `addons.retryLoad`).
4734
+ * AlertCenter dismisses the corresponding `AddonLoadFailed` alert
4735
+ * on this event.
4736
+ */
4737
+ EventCategory["AddonLoadRecovered"] = "addon.load-recovered";
4738
+ /**
4739
+ * Monitor scheduled the next retry for a failed addon. Transient —
4740
+ * surfaced to the UI for live-updating the "next retry in Ns"
4741
+ * countdown on the Addons page row, NOT persisted as an alert.
4742
+ */
4743
+ EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
4744
+ /**
4745
+ * A liveness invariant of this node is false — it has no devices, every
4746
+ * camera is offline, or a camera that was recording has stopped producing
4747
+ * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
4748
+ * `findingId` is stable across ticks), only after its boot grace period.
4749
+ * AlertCenter raises a persistent operator-visible alert.
4750
+ *
4751
+ * It exists because on 2026-08-03 the hub ran four hours with zero devices
4752
+ * and zero recordings while every surface stayed quiet.
4753
+ *
4754
+ * Payload: `{ findingId, severity, title, message, deviceId? }`.
4755
+ */
4756
+ EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
4757
+ /**
4758
+ * A previously reported liveness fault is true again. AlertCenter dismisses
4759
+ * the matching `SystemLivenessFailed` alert.
4760
+ *
4761
+ * Payload: `{ findingId }`.
4762
+ */
4763
+ EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
4764
+ /**
4765
+ * Monitor is attempting to reload a failed addon NOW. UI uses this
4766
+ * to show a spinner during the retry attempt. Same transient nature
4767
+ * as AddonRetryScheduled.
4768
+ */
4769
+ EventCategory["AddonRetryAttempting"] = "addon.retry-attempting";
4770
+ EventCategory["DeviceRegistered"] = "device.registered";
4771
+ EventCategory["DeviceUnregistered"] = "device.unregistered";
4772
+ EventCategory["DeviceEnabled"] = "device.enabled";
4773
+ EventCategory["DeviceDisabled"] = "device.disabled";
4774
+ EventCategory["DeviceSettingsUpdated"] = "device.settings-updated";
4775
+ /**
4776
+ * Emitted when the set of native capability providers bound to a device
4777
+ * changes — e.g. an addon registers a new native cap via
4778
+ * `DeviceContext.registerNativeCap`, or all native bindings for a device
4779
+ * are cleared on removal. Hub consumers re-resolve device-proxy routes
4780
+ * when this fires.
4781
+ */
4782
+ EventCategory["DeviceBindingsChanged"] = "device.bindings-changed";
4783
+ /**
4784
+ * Emitted when the operator-organisational meta surface changes
4785
+ * (`name` / `location` / `disabled`). Payload: `{deviceId, field,
4786
+ * value}`. Live consumers (UI device list, alert center) react
4787
+ * without polling. Distinct from `DeviceSettingsUpdated` which
4788
+ * fires on hardware-config changes (host/port/credentials/etc).
4789
+ */
4790
+ EventCategory["DeviceMetaChanged"] = "device.meta-changed";
4791
+ /**
4792
+ * Emitted by `BaseDevice.updateSourceInfo()` after a successful patch
4793
+ * to the device's upstream-system identity / rendering envelope. The
4794
+ * full new SourceInfo travels in the payload so cross-process listeners
4795
+ * (UI, export adapters) don't need to re-read the meta blob.
4796
+ *
4797
+ * Payload: `{deviceId, sourceInfo}`.
4798
+ *
4799
+ * Distinct from `DeviceMetaChanged` which covers the operator-edited
4800
+ * surface (`name` / `location` / `disabled`). The two share the
4801
+ * persistence layer (both ride on `device-manager.setMetadata` for
4802
+ * SourceInfo, or `setName`/`setLocation`/`setDisabled` for the meta
4803
+ * fields) but consumers care about different subsets.
4804
+ */
4805
+ EventCategory["DeviceSourceInfoChanged"] = "device.source-info-changed";
4806
+ /**
4807
+ * Emitted by DeviceStreamWiringService after a successful
4808
+ * `stream-broker.registerDeviceStreams` call. Payload includes
4809
+ * deviceId — consumers look up the full registered device info via
4810
+ * `brokerManager.getRegisteredDevice(deviceId)`.
4811
+ *
4812
+ * Replaces the legacy `StreamRouterService.onDeviceRegistered` callback.
4813
+ */
4814
+ EventCategory["DeviceStreamsRegistered"] = "device.streams-registered";
4815
+ /**
4816
+ * Device-level "fully provisioned" signal — the EXPORT trigger. Emitted once
4817
+ * a device's persisted export-relevant shape (its `DeviceFeature` set) is
4818
+ * established or changes, carrying `{deviceId, fingerprint, generation}`.
4819
+ * Export adapters (Alexa / HAP) react to THIS instead of the chatty per-cap
4820
+ * `DeviceBindingsChanged`, turning a boot's incomplete→complete trickle into
4821
+ * one clean delta. `fingerprint` is `canonicalDeviceFingerprint(shape)`.
4822
+ *
4823
+ * Spec: docs/superpowers/specs/2026-06-01-alexa-hap-export-reconciler-design.md
4824
+ */
4825
+ EventCategory["DeviceProvisioned"] = "device.provisioned";
4826
+ /**
4827
+ * Fires once when a device's initial feature-probe completes successfully
4828
+ * (lastProbedAt 0→>0); exported shape is now stable. Telemetry (may be
4829
+ * dropped) — consumers MUST also gate on the device record's `probed` flag.
4830
+ */
4831
+ EventCategory["DeviceReady"] = "device.ready";
4832
+ EventCategory["IntegrationEnabled"] = "integration.enabled";
4833
+ EventCategory["IntegrationDisabled"] = "integration.disabled";
4834
+ EventCategory["IntegrationDeleted"] = "integration.deleted";
4835
+ /** Emitted when a broker connection's status flips (connected /
4836
+ * disconnected / error). Carries `{ brokerId, status, error? }`. */
4837
+ EventCategory["BrokerStatusChanged"] = "broker.status-changed";
4838
+ /** Emitted for every subscription-matched message routed by a
4839
+ * broker provider. Carries `{ brokerId, subscriptionId, key, payload }`.
4840
+ * Consumers filter by `brokerId` + `subscriptionId` in the handler. */
4841
+ EventCategory["BrokerMessage"] = "broker.message";
4842
+ EventCategory["ProviderStarted"] = "provider.started";
4843
+ EventCategory["ProviderStopped"] = "provider.stopped";
4844
+ EventCategory["ProcessCrashed"] = "process.crashed";
4845
+ EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
4846
+ EventCategory["ProcessRestarted"] = "process.restarted";
4847
+ EventCategory["RecordingStarted"] = "recording.started";
4848
+ EventCategory["RecordingStopped"] = "recording.stopped";
4849
+ EventCategory["RecordingError"] = "recording.error";
4850
+ EventCategory["RecordingHealthDegraded"] = "recording.health.degraded";
4851
+ EventCategory["RecordingStorageCritical"] = "recording.storage.critical";
4852
+ EventCategory["RecordingSegmentWritten"] = "recording.segment.written";
4853
+ EventCategory["RecordingPolicyFallback"] = "recording.policy.fallback";
4854
+ EventCategory["RecordingRetentionCompleted"] = "recording.retention.completed";
4855
+ /** Export render progress (0–100). Telemetry (D8): a lost tick is a stale
4856
+ * progress bar the client reconciles via `recordingExport.getExport`. */
4857
+ EventCategory["RecordingExportProgress"] = "recording.export.progress";
4858
+ EventCategory["RecordingExportCompleted"] = "recording.export.completed";
4859
+ EventCategory["RecordingExportFailed"] = "recording.export.failed";
4860
+ EventCategory["DetectionEvent"] = "detection.event";
4861
+ EventCategory["SessionTrackNew"] = "session.track.new";
4862
+ EventCategory["SessionTrackExpired"] = "session.track.expired";
4863
+ EventCategory["BenchmarkProgress"] = "benchmark.progress";
4864
+ EventCategory["PlatformProbePhase"] = "platform-probe.phase";
4865
+ EventCategory["PipelineProgress"] = "pipeline.progress";
4866
+ /** Per-frame execution trace emitted by the pipeline executor for live observability. */
4867
+ EventCategory["PipelineTrace"] = "pipeline.trace";
4868
+ /**
4869
+ * Raw inference output emitted by `addon-pipeline-runner` after running the
4870
+ * detection pipeline on a frame. Carries the `FrameResult` (with
4871
+ * `detections[]`, `width`/`height`, timing debug) — never the frame
4872
+ * buffer itself. Hub-side consumers (analysis pipeline, class filters,
4873
+ * notifications) subscribe to this and re-emit `detection.result` after
4874
+ * post-processing.
4875
+ */
4876
+ EventCategory["PipelineInferenceResult"] = "pipeline.inference-result";
4877
+ /**
4878
+ * Camera lifecycle event emitted by `addon-pipeline-orchestrator` when it
4879
+ * assigns or unassigns a camera to/from an agent. Carries no frame data;
4880
+ * pure observability for UI dashboards and metrics consumers.
4881
+ */
4882
+ EventCategory["PipelineCameraAssigned"] = "pipeline.camera-assigned";
4883
+ EventCategory["PipelineCameraUnassigned"] = "pipeline.camera-unassigned";
4884
+ /**
4885
+ * Per-camera pipeline config was mutated by the orchestrator
4886
+ * (3-level settings change via `setAgentAddonDefaults` /
4887
+ * `setCameraStepToggle` / `setCameraPipelineForAgent` or a
4888
+ * pipeline-scoped `applyDeviceSettingsPatch`). Orchestrator
4889
+ * subscribes to its own emission to hot-reload the assigned runner
4890
+ * via `attachCamera` so the next frame executes against the new
4891
+ * engine/steps without waiting for a rebalance or the next
4892
+ * `DeviceStreamsRegistered` cycle.
4893
+ */
4894
+ EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
4895
+ /**
4896
+ * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
4897
+ * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
4898
+ * capabilities — at boot, on agent online/offline, and on an ingest-node
4899
+ * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
4900
+ * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
4901
+ * cross-process `getIngestOwner` query (push the authority's decision instead
4902
+ * of polling it on the hot path). Idempotent state — re-emitted on every
4903
+ * topology change, so a dropped event self-heals on the next one (plus the
4904
+ * broker's long backstop reconcile query).
4905
+ */
4906
+ EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
4907
+ /**
4908
+ * Periodic snapshot of per-node pipeline-runner load
4909
+ * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
4910
+ * subscribe instead of polling `pipelineRunner.getLocalLoad`.
4911
+ * `nodeId` carried in the payload + on `event.source.nodeId`.
4912
+ */
4913
+ EventCategory["PipelineRunnerLoadSnapshot"] = "pipeline.runner-load-snapshot";
4914
+ /**
4915
+ * Periodic snapshot of per-camera pipeline metrics (`CameraMetrics`
4916
+ * + `deviceId` + `nodeId`). Emitted ~1Hz by every runner for each
4917
+ * attached camera. UI subscribes to drive overlay phase / fps /
4918
+ * inference time without polling `getCameraMetrics`.
4919
+ */
4920
+ EventCategory["PipelineCameraMetricsSnapshot"] = "pipeline.camera-metrics-snapshot";
4921
+ /**
4922
+ * Periodic snapshot of stream-broker per-broker statistics (input
4923
+ * fps, decoded fps, bitrate, codec). Emitted ~1Hz by every
4924
+ * stream-broker process for each active broker so the UI can drive
4925
+ * the Stream / Cluster dashboards without polling
4926
+ * `streamBroker.listAllProfileSlots` and friends.
4927
+ */
4928
+ EventCategory["StreamBrokerMetricsSnapshot"] = "stream-broker.metrics-snapshot";
4929
+ /**
4930
+ * Cap event fired by `stream-broker` when a profile slot enters
4931
+ * "demanded" state — a cam stream has been assigned and at least one
4932
+ * consumer (RTSP restream, decoded subscriber, WebRTC session, …) is
4933
+ * present. Camera-provider addons (Reolink Baichuan push, …)
4934
+ * subscribe to this category to start their underlying transport
4935
+ * lazily. Payload: `{ deviceId, camStreamId, profile }`.
4936
+ */
4937
+ EventCategory["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
4938
+ /**
4939
+ * Cap event fired by `stream-broker` when the last consumer leaves a
4940
+ * previously-demanded cam stream. Providers tear down their
4941
+ * underlying transport on receipt. Payload: `{ deviceId, camStreamId }`.
4942
+ */
4943
+ EventCategory["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
4944
+ /**
4945
+ * Cap event fired by `stream-broker` when a broker fails to dial a
4946
+ * managed-loopback source (today: `pull-rfc4571`) and the publisher
4947
+ * needs to refresh the cached URL. The lib's TCP server idle-tears-down
4948
+ * on its own schedule, so a re-publish with a fresh `host:port` is the
4949
+ * only way to keep the broker dialable. Camera providers (Reolink
4950
+ * Baichuan native, …) subscribe and respond by re-running their publish
4951
+ * pipeline.
4952
+ * Payload: `{ deviceId, camStreamId, brokerId }`.
4953
+ */
4954
+ EventCategory["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
4955
+ /**
4956
+ * A camera provider changed a device's stream parameters (codec /
4957
+ * resolution / bitrate / a stream added or removed). A LOW-LATENCY NUDGE
4958
+ * for the stream-broker's catalog reconcile: it carries NO authoritative
4959
+ * data — the broker re-PULLS that device's `stream-catalog` cap on receipt
4960
+ * (the 30s reconcile poll is the backstop if this nudge is dropped). Emitted
4961
+ * by providers from `stream-params.setProfile`. Payload: `{ deviceId }`.
4962
+ */
4963
+ EventCategory["StreamParamsChanged"] = "stream-params.changed";
4964
+ /**
4965
+ * Generic per-device runtime-state change. Fired by `device-manager`
4966
+ * whenever a persisted slice in any cap's `runtimeState` shape
4967
+ * mutates. Payload: `{deviceId, capName, slice}`. Subscribers are
4968
+ * the `deviceState` cap router (cross-process listeners) and the
4969
+ * deviceProxy reactive bindings (`device.state.<capName>.value`).
4970
+ * Cap-specific events (`battery.onStatusChanged`, …) still fire
4971
+ * — they're authoritative for callers that want a typed payload
4972
+ * without filtering on `capName`.
4973
+ */
4974
+ EventCategory["DeviceStateChanged"] = "device.state-changed";
4975
+ /**
4976
+ * Frame occupancy for a camera CHANGED — a tracked object was gained or
4977
+ * lost. Carries `{ deviceId, totalObjects, byClass, zones }`.
4978
+ *
4979
+ * Emitted only on a change, so a steady scene is silent. It exists so a
4980
+ * client can stop polling `zoneAnalytics.getCurrentSnapshot`: that was the
4981
+ * one live badge with no push signal at all, and it cost a request every
4982
+ * four seconds per visible camera.
4983
+ *
4984
+ * Like every event it is telemetry and may be dropped ([D8]) — a consumer
4985
+ * keeps a slow reconcile rather than trusting it alone.
4986
+ */
4987
+ EventCategory["ZoneAnalyticsOccupancyChanged"] = "zone-analytics.occupancy-changed";
4988
+ /**
4989
+ * Cap event fired by every device that registers the `battery`
4990
+ * capability. Mirrors the cap definition's `onStatusChanged`. Carries
4991
+ * `{ deviceId, status: BatteryStatus }`. Subscribers (alert center,
4992
+ * snapshot wrapper, UI) react to charge/sleep transitions without
4993
+ * polling `batteryCapability.getStatus`.
4994
+ */
4995
+ EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
4996
+ /**
4997
+ * Emitted by the battery cap provider WHEN `wakeForStream` enters the
4998
+ * "wake in progress" window — between the Baichuan wake-up issue and
4999
+ * the camera's first dialed-back RTP packet. The stream-broker
5000
+ * manager subscribes to flip the per-broker placeholder reason to
5001
+ * `'waking'` so viewers see a labelled "WAKING UP" tile instead of
5002
+ * the generic `'reconnecting'` frame. Carries `{ deviceId }`. The
5003
+ * complementary "wake complete" signal is the existing
5004
+ * `BatteryOnStatusChanged { sleeping: false }` event.
5005
+ */
5006
+ EventCategory["BatteryOnWakeStarted"] = "battery.onWakeStarted";
5007
+ /**
5008
+ * Cap event fired by every device that registers the `doorbell`
5009
+ * capability. Mirrors `doorbellCapability.events.onPressed`. Carries
5010
+ * `{ deviceId, timestamp }`. Operators consuming the UI subscribe
5011
+ * here to render transient ring toasts and a "Recent presses" row
5012
+ * on the device detail page.
5013
+ */
5014
+ EventCategory["DoorbellOnPressed"] = "doorbell.onPressed";
5015
+ /**
5016
+ * Cap event fired by every device that registers the `event-emitter`
5017
+ * capability. Mirrors `eventEmitterCapability.events.onEvent`. Carries
5018
+ * `{ deviceId, eventType, data, timestamp, seq }` — the device's EXACT
5019
+ * declared event verbatim (NO normalization). Subscribers (UI event
5020
+ * stream, advanced-notifier rules) react to fired events without
5021
+ * holding a cap reference.
5022
+ */
5023
+ EventCategory["EventEmitted"] = "event-emitter.event";
5024
+ /**
5025
+ * Periodic snapshot of the per-node detection-pipeline engine
5026
+ * registry (loaded engines, models resident, in-use cameras, idle
5027
+ * TTL). Emitted ~0.2Hz (every 5 s) by every detection-pipeline
5028
+ * process. The Engines tab subscribes to drive its inventory view
5029
+ * without polling `pipelineExecutor.listLoadedEngines`.
5030
+ */
5031
+ EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
5032
+ /**
5033
+ * Per-node detection-engine runtime-provisioning transition. Emitted by
5034
+ * the detection-pipeline provider on every state change of its lazy
5035
+ * engine-provisioning machine (idle → installing → verifying → ready,
5036
+ * or → failed with a `nextRetryAt`). Payload is the
5037
+ * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
5038
+ * node. The Pipeline page subscribes to drive a live "installing
5039
+ * OpenVINO… / ready" indicator per node without polling
5040
+ * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
5041
+ * also reads the cap snapshot on mount / reconnect. Phase 2.
5042
+ */
5043
+ EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
5044
+ /**
5045
+ * Cluster topology snapshot. Carries the same payload returned by
5046
+ * `nodes.topology` (every reachable node + addons + processes).
5047
+ * Emitted by the hub on any agent / addon lifecycle change
5048
+ * (debounced) plus a periodic safety net. Replaces UI polling on
5049
+ * `nodes.topology` — admin-ui dashboards subscribe to drive the
5050
+ * cluster view directly from the event payload.
5051
+ */
5052
+ EventCategory["ClusterTopologySnapshot"] = "cluster.topology-snapshot";
5053
+ /**
5054
+ * Periodic per-node system metrics snapshot (CPU / memory / GPU /
5055
+ * disk / network). Emitted ~0.2 Hz by the metrics-provider addon
5056
+ * for each node. Drives the dashboard SystemStatus / ProcessResources
5057
+ * widgets without polling `metricsProvider.getCurrent`.
5058
+ */
5059
+ EventCategory["MetricsNodeResourcesSnapshot"] = "metrics.node-resources-snapshot";
5060
+ /**
5061
+ * Periodic per-node process-tree snapshot (camstack-related pids
5062
+ * with ghost / managed / root classification). Emitted ~0.2 Hz by
5063
+ * the metrics-provider addon. Drives the Cluster → Processes tab
5064
+ * without polling `metricsProvider.listNodeProcesses`.
5065
+ */
5066
+ EventCategory["MetricsNodeProcessesSnapshot"] = "metrics.node-processes-snapshot";
5067
+ /**
5068
+ * Capability binding change event emitted by `addon-pipeline-orchestrator`
5069
+ * when a user changes which addon implements a cap on a node. Subscribed
5070
+ * by every kernel process to update its local `preferredProviderRegistry`
5071
+ * so future capability lookups respect the new binding.
5072
+ */
5073
+ /**
5074
+ * A capability binding was changed for a node — addon X now provides
5075
+ * capability `cap` on node `nodeId`. Lives under the generic
5076
+ * `capability.*` namespace because capability bindings are a kernel-
5077
+ * level concept used by many addons, not strictly pipeline-scoped.
5078
+ */
5079
+ EventCategory["CapabilityBindingChanged"] = "capability.binding-changed";
5080
+ EventCategory["ModelDownloadProgress"] = "model.download.progress";
5081
+ EventCategory["AgentRegistered"] = "agent.registered";
5082
+ EventCategory["AgentUnregistered"] = "agent.unregistered";
5083
+ EventCategory["AgentOnline"] = "agent.online";
5084
+ EventCategory["AgentOffline"] = "agent.offline";
5085
+ /** Forked worker process (e.g. hub/pipeline) connected to the broker. */
5086
+ EventCategory["WorkerOnline"] = "worker.online";
5087
+ /** Forked worker process disconnected from the broker. */
5088
+ EventCategory["WorkerOffline"] = "worker.offline";
5089
+ EventCategory["AgentTaskDispatched"] = "agent.task.dispatched";
5090
+ EventCategory["AgentTaskAssigned"] = "agent.task.assigned";
5091
+ EventCategory["AgentTrpcConnected"] = "agent.trpc.connected";
5092
+ EventCategory["AgentWsConnected"] = "agent.ws.connected";
5093
+ EventCategory["AgentWsDisconnected"] = "agent.ws.disconnected";
5094
+ EventCategory["AgentBackupActivated"] = "agent.backup.activated";
5095
+ EventCategory["OrchestrationSettingsUpdated"] = "orchestration.settings-updated";
5096
+ /**
5097
+ * Per-agent hwaccel preference changed (user override set, cleared, or
5098
+ * re-probed). Observability event — decoders pull the current pref at
5099
+ * `createSession`, so running sessions keep their current backend until
5100
+ * they rotate naturally (camera add/remove, stream restart). Future
5101
+ * work can wire a listener in stream-broker that force-rotates live
5102
+ * sessions; for now this event powers logs + admin-UI toast feedback.
5103
+ */
5104
+ EventCategory["PipelineAgentHwaccelChanged"] = "pipeline.agent-hwaccel-changed";
5105
+ EventCategory["MotionAnalysis"] = "detection.motion-analysis";
5106
+ /** All raw motion zones from CCL before minArea filter — for UI debug overlay. */
5107
+ EventCategory["MotionZonesRaw"] = "detection.motion-zones-raw";
5108
+ /**
5109
+ * Per-camera motion phase transition (`watching ↔ active`) emitted
5110
+ * by the runner. Mirrors the `motion.onMotionChanged` cap event
5111
+ * surface — payload `MotionOnMotionChangedPayload` carries
5112
+ * `{deviceId, detected, timestamp, source, regions?}`. Subscribers
5113
+ * include addons that need to react to motion state without
5114
+ * polling the runtime-state mirror.
5115
+ */
5116
+ EventCategory["MotionOnMotionChanged"] = "motion.on-motion-changed";
5117
+ EventCategory["DetectionResult"] = "detection.result";
5118
+ EventCategory["DetectionRaw"] = "detection.raw";
5119
+ EventCategory["DetectionCameraNative"] = "detection.camera-native";
5120
+ /**
5121
+ * Canonical per-chunk live audio pipeline output. Payload is
5122
+ * `PipelineAudioInferenceResultPayload` carrying a full `AudioResult`
5123
+ * (level + detections + debug). Lives on the pipeline.* namespace
5124
+ * alongside `pipeline.inference-result` (video) for symmetry.
5125
+ */
5126
+ EventCategory["PipelineAudioInferenceResult"] = "pipeline.audio-inference-result";
5127
+ EventCategory["DetectionPhaseTransition"] = "detection.phase-transition";
5128
+ EventCategory["ProviderMotion"] = "provider.motion";
5129
+ EventCategory["ProviderDetection"] = "provider.detection";
5130
+ EventCategory["EnrichmentEmbeddingStored"] = "enrichment.embedding.stored";
5131
+ EventCategory["EnrichmentSceneStateChanged"] = "enrichment.scene.state-changed";
5132
+ EventCategory["EnrichmentActivitySummary"] = "enrichment.activity.summary";
5133
+ /**
5134
+ * Unified track-lifecycle event: ONE category carrying `phase:
5135
+ * 'start' | 'update' | 'end'` with a rich, typed
5136
+ * `PipelineAnalyticsTrackLifecyclePayload`. Supersedes the thin
5137
+ * `PipelineAnalyticsTrackStarted` / `PipelineAnalyticsTrackEnded`
5138
+ * pair — a consumer subscribes once and branches on `phase`.
5139
+ */
5140
+ EventCategory["PipelineAnalyticsTrackLifecycle"] = "pipeline-analytics.track-lifecycle";
5141
+ /**
5142
+ * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
5143
+ * (`phase:'start'`). Kept as a soft alias — no known subscribers.
5144
+ */
5145
+ EventCategory["PipelineAnalyticsTrackStarted"] = "pipeline-analytics.track-started";
5146
+ /**
5147
+ * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
5148
+ * (`phase:'end'`). Kept as a soft alias — no known subscribers.
5149
+ */
5150
+ EventCategory["PipelineAnalyticsTrackEnded"] = "pipeline-analytics.track-ended";
5151
+ EventCategory["PipelineAnalyticsDetectionEvent"] = "pipeline-analytics.detection-event";
5152
+ EventCategory["PipelineAnalyticsFrameTracked"] = "pipeline-analytics.frame-tracked";
5153
+ /**
5154
+ * Fired by `addon-post-analysis` when a parked (stationary) object appears
5155
+ * (a track was promoted to a stationary-registry entry) or departs (the
5156
+ * object moved / was removed). Telemetry (D8): lossy, drives a UI refresh of
5157
+ * the dedicated "Stationary" section — never the live event feed. Payload:
5158
+ * `{ deviceId, entryId, className, phase:'appeared'|'departed', timestamp }`.
5159
+ */
5160
+ EventCategory["PipelineAnalyticsStationaryChanged"] = "pipeline-analytics.stationary-changed";
5161
+ /**
5162
+ * Fired by `addon-post-analysis` when a package-drop is confirmed inside
5163
+ * a package zone — a newly-appeared stationary object of a package class
5164
+ * that cleared the class / zone / dwell / size gates. Payload:
5165
+ * `PipelineAnalyticsPackageDeliveredPayload` carrying `{ deviceId,
5166
+ * entryId, className, zoneIds, keyFrameMediaKey?, bbox, timestamp }`.
5167
+ * Telemetry (D8): the durable record is the `package-events` store row;
5168
+ * this bus topic drives notifier rules + live UI. See
5169
+ * docs/superpowers/specs/2026-07-17-package-zones-design.md §5.1.
5170
+ */
5171
+ EventCategory["PipelineAnalyticsPackageDelivered"] = "pipeline-analytics.package-delivered";
5172
+ /**
5173
+ * Fired by `addon-post-analysis` when a previously-delivered package
5174
+ * leaves its zone (the stationary entry departed — moved or swept).
5175
+ * Payload: `PipelineAnalyticsPackagePickedUpPayload` carrying
5176
+ * `{ deviceId, entryId, deliveredEventId, className, timestamp }`.
5177
+ * Telemetry (D8). See package-zones-design §5.2.
5178
+ */
5179
+ EventCategory["PipelineAnalyticsPackagePickedUp"] = "pipeline-analytics.package-picked-up";
5180
+ /**
5181
+ * Fired by `addon-post-analysis` whenever a gallery face row changes:
5182
+ * `kind:'buffered'` a new detected face was persisted, `'assigned'` /
5183
+ * `'unassigned'` its identity link changed, `'deleted'` the row was
5184
+ * removed. Telemetry semantics (D8): a lost event only delays a refresh,
5185
+ * never a correctness issue. Payload `PipelineAnalyticsFaceGalleryChangedPayload`.
5186
+ */
5187
+ EventCategory["PipelineAnalyticsFaceGalleryChanged"] = "pipeline-analytics.face-gallery-changed";
5188
+ /**
5189
+ * Fired by `addon-post-analysis` whenever a gallery plate row changes:
5190
+ * `kind:'buffered'` a new read was persisted (`PlateRecognizer.onTrackEnd`),
5191
+ * `'assigned'` / `'unassigned'` its vehicle link changed
5192
+ * (`PlateGalleryProvider`), `'deleted'` the row was removed. Telemetry
5193
+ * semantics (D8): a lost event only delays a refresh, never a correctness
5194
+ * issue. Payload `PipelineAnalyticsPlateGalleryChangedPayload`.
5195
+ */
5196
+ EventCategory["PipelineAnalyticsPlateGalleryChanged"] = "pipeline-analytics.plate-gallery-changed";
5197
+ EventCategory["FrigateLiveEvent"] = "frigate.live-event";
5198
+ EventCategory["CameraStreamsProfileSlotsChanged"] = "camera-streams.onProfileSlotsChanged";
5199
+ /**
5200
+ * Stream-broker health watchdog. Per-broker (deviceId/profile) emission.
5201
+ * `stream.offline` fires after STREAM_STALE_TIMEOUT_MS without an encoded
5202
+ * packet on an active broker. `stream.online` fires on first packet
5203
+ * after a stale gap (or on initial first packet). Payload includes
5204
+ * the assigned camStreamId as `profileKey`.
5205
+ */
5206
+ EventCategory["StreamOnline"] = "stream.online";
5207
+ EventCategory["StreamOffline"] = "stream.offline";
5208
+ EventCategory["NetworkTunnelStarted"] = "network.tunnel.started";
5209
+ EventCategory["NetworkTunnelStopped"] = "network.tunnel.stopped";
5210
+ /** Fired by the `local-network` cap when the host's interface set
5211
+ * changes (new IP from DHCP, VPN connect, docker bridge added). */
5212
+ EventCategory["LocalNetworkChanged"] = "network.local.changed";
5213
+ /**
5214
+ * Fired by a `mesh-network` provider (Tailscale, …) when its
5215
+ * mesh-reachable host changes (join / leave / MagicDNS or 100.x IP
5216
+ * change). Lets `local-network` fold the mesh endpoint into its
5217
+ * connection-endpoint list without a cross-package import. Payload
5218
+ * carries the preferred host (`host: ''` = no longer reachable),
5219
+ * the hub port, and the scheme. Telemetry-grade (D8): consumers
5220
+ * pull-reconcile from the provider's `getStatus` on reconnect. */
5221
+ EventCategory["MeshNetworkChanged"] = "network.mesh.changed";
5222
+ EventCategory["BackupCompleted"] = "backup.completed";
5223
+ EventCategory["BackupRestored"] = "backup.restored";
5224
+ EventCategory["NotificationDispatched"] = "notification.dispatched";
5225
+ EventCategory["NotificationFailed"] = "notification.failed";
5226
+ EventCategory["DeviceUpdated"] = "device.updated";
5227
+ /**
5228
+ * Transport-level connectivity. Emitted by the device driver when
5229
+ * the underlying control socket actually connects / disconnects
5230
+ * (Baichuan TCP, ONVIF probe response, RTSP DESCRIBE, …) — NOT for
5231
+ * power-state transitions on a battery camera. For battery wake /
5232
+ * doze cycles see `DeviceAwake` / `DeviceSleeping`.
5233
+ */
5234
+ EventCategory["DeviceOnline"] = "device.online";
5235
+ /**
5236
+ * Stream-broker watchdog — emitted when no encoded packet has been
5237
+ * received for STREAM_STALE_TIMEOUT_MS on an active broker (rtsp or
5238
+ * push). Paired with DeviceOnline which fires on first packet after
5239
+ * a stale gap. Payload: DeviceStreamHealthPayload.
5240
+ */
5241
+ EventCategory["DeviceOffline"] = "device.offline";
5242
+ /**
5243
+ * Battery cam woke up — physical power-state transition reported
5244
+ * by the device firmware. Distinct from `DeviceOnline` so a UI panel
5245
+ * watching power state doesn't flap on every UDP socket reconnect.
5246
+ */
5247
+ EventCategory["DeviceAwake"] = "device.awake";
5248
+ /**
5249
+ * Battery cam went to sleep. See `DeviceAwake`.
5250
+ */
5251
+ EventCategory["DeviceSleeping"] = "device.sleeping";
5252
+ EventCategory["RetentionCleanup"] = "retention.cleanup";
5253
+ /**
5254
+ * Legacy bulk-update progress snapshot (payload `BulkUpdateState`). No longer
5255
+ * emitted — F3 removed the coordinator that produced it; "Update all" now runs
5256
+ * as one lifecycle engine job (`AddonsJobProgress`/`AddonsJobLog`). Retained
5257
+ * (with `BulkUpdateState`) only to avoid regenerating the event maps; removed
5258
+ * in F4 once live bulk progress is re-implemented over the engine events.
5259
+ */
5260
+ EventCategory["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
5261
+ EventCategory["AddonsJobProgress"] = "addons.job-progress";
5262
+ EventCategory["AddonsJobLog"] = "addons.job-log";
5263
+ /**
5264
+ * A container's child visibility toggled (hidden/shown). Emitted by the
5265
+ * `accessories` cap when a child device is hidden or revealed.
5266
+ * Payload: `{ deviceId, childDeviceId, hidden }`.
5267
+ */
5268
+ EventCategory["AccessoriesChildVisibilityChanged"] = "accessories.onChildVisibilityChanged";
5269
+ /**
5270
+ * A container's child set changed (children added/removed/reordered).
5271
+ * Payload: `{ deviceId, childDeviceIds, hiddenChildIds }`.
5272
+ */
5273
+ EventCategory["AccessoriesChanged"] = "accessories.onAccessoriesChanged";
5274
+ /**
5275
+ * Progress update from a running model conversion job.
5276
+ * Payload: `{ kind: 'model-convert', phase, sessionId?, pct?, detail? }`.
5277
+ * Emitted by `addon-model-studio` on the converting node.
5278
+ */
5279
+ EventCategory["ModelConvertProgress"] = "model-convert.progress";
5280
+ return EventCategory;
5281
+ }({});
5282
5282
  Object.fromEntries([
5283
5283
  {
5284
5284
  id: "overview",
@@ -6286,17 +6286,6 @@ var ProfileSlotSchema = object({
6286
6286
  errorMessage: string().optional()
6287
6287
  });
6288
6288
  /**
6289
- * The broker id keying a physical SOURCE stream: `${deviceId}/${camStreamId}`.
6290
- * This is the broker's internal source key (`brokerIdFor` in the manager) — a
6291
- * `camStreamId` like `native:main` — as opposed to the public profile alias
6292
- * {@link makeProfileBrokerId}. Used to resolve a source's restream endpoint
6293
- * (e.g. routing a transcode's ffmpeg input through the broker so it rides the
6294
- * single source dial instead of opening a second camera connection).
6295
- */
6296
- function makeSourceBrokerId(deviceId, camStreamId) {
6297
- return `${deviceId}/${camStreamId}`;
6298
- }
6299
- /**
6300
6289
  * Zod schema for StreamSourceEntry — the canonical stream descriptor
6301
6290
  * exposed by ICameraDevice.getStreamSources() and consumed by the broker.
6302
6291
  */
@@ -7113,6 +7102,14 @@ var EncodeProfileSchema = object({
7113
7102
  "main",
7114
7103
  "high"
7115
7104
  ]).optional(),
7105
+ /**
7106
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7107
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7108
+ * it, or it ships a stream that does not match its own advertisement — the
7109
+ * defect class that kept HomeKit black for a year and that Alexa carried
7110
+ * silently. Optional because a browser negotiates the level itself.
7111
+ */
7112
+ level: string().optional(),
7116
7113
  width: number().int().positive().optional(),
7117
7114
  height: number().int().positive().optional(),
7118
7115
  fps: number().positive().optional(),
@@ -7160,6 +7157,55 @@ var EncodeProfileSchema = object({
7160
7157
  outputArgs: array(string()).optional()
7161
7158
  });
7162
7159
  /**
7160
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7161
+ * Baseline because it is the one profile every consumer in this repo decodes
7162
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7163
+ */
7164
+ var BASE_LIVE_EGRESS_PROFILE = {
7165
+ video: {
7166
+ codec: "h264",
7167
+ profile: "baseline",
7168
+ level: "3.1",
7169
+ width: 1280,
7170
+ height: 720,
7171
+ fps: 25,
7172
+ bitrateKbps: 2500,
7173
+ gopFrames: 25,
7174
+ bf: 0,
7175
+ preset: "veryfast",
7176
+ tune: "zerolatency"
7177
+ },
7178
+ audio: "passthrough"
7179
+ };
7180
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7181
+ /**
7182
+ * Alexa's egress profile. Alexa's audio ALSO rides the WebRTC session's own
7183
+ * audio plane (out-of-band), so this is `passthrough` exactly like the browser
7184
+ * — see the ADR for why the previous in-band Opus was encoded and discarded.
7185
+ */
7186
+ /**
7187
+ * Alexa's egress asks for OPUS, and that is a change with a history.
7188
+ *
7189
+ * The previous profile encoded Opus IN-BAND into an MPEG-TS, where it was
7190
+ * discarded: Opus is `stream_type 0x06` and the broker's demuxer maps only
7191
+ * `0x0f` (aac), `0x03`/`0x04` (mp2) and `0x81` (ac3). Every frame it produced
7192
+ * died at the demuxer, so the encode was replaced with `passthrough` — correct,
7193
+ * because paying libopus for nothing is worse than silence.
7194
+ *
7195
+ * But `passthrough` means the egress emits NO audio at all, and Alexa's audio
7196
+ * does not arrive by magic: it rides the published stream, which is what the
7197
+ * WebRTC session dials. So the Echo had video and silence either way.
7198
+ *
7199
+ * Opus here now reaches the egress AUDIO SIDECAR — a separate RTP leg the
7200
+ * restreamer grafts onto its SDP — which never touches the MPEG-TS demuxer that
7201
+ * killed the in-band attempt. Same codec, different plane, and this one the
7202
+ * consumer can actually negotiate.
7203
+ */
7204
+ var ALEXA_EGRESS_PROFILE = {
7205
+ ...BASE_LIVE_EGRESS_PROFILE,
7206
+ audio: { codec: "opus" }
7207
+ };
7208
+ /**
7163
7209
  * Deep wiring healthcheck — snapshot of active reachability probes across
7164
7210
  * every declared capability + widget of every installed plugin, on every
7165
7211
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7209,6 +7255,154 @@ object({
7209
7255
  })
7210
7256
  });
7211
7257
  /**
7258
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7259
+ * pipeline functions an operator thinks in terms of.
7260
+ *
7261
+ * ## This file adds no state
7262
+ *
7263
+ * Every switch here is a VIEW onto an authority that already existed
7264
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7265
+ * group is that there is exactly one place each function is turned off, and
7266
+ * the group routes to it:
7267
+ *
7268
+ * | Switch | Authority | Proven "off stops the work" gate |
7269
+ * | --- | --- | --- |
7270
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7271
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7272
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7273
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7274
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7275
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7276
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7277
+ *
7278
+ * ## The two switches whose authority is not on this server
7279
+ *
7280
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7281
+ * in "the group stores nothing" — it is the purest form of it: the camera
7282
+ * holds the fact, every read is a read-through, and there is no server-side
7283
+ * copy that could drift. Their availability therefore cannot come from
7284
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7285
+ * is filtered out there); it comes from the cap's own camera-probed
7286
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7287
+ * THIS camera rather than for the device type
7288
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7289
+ *
7290
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7291
+ *
7292
+ * Every other switch means *this camera's function is doing its job*, so
7293
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7294
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7295
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7296
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7297
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7298
+ * worse than either surface not having one.
7299
+ *
7300
+ * Two consequences follow and both are load-bearing:
7301
+ *
7302
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7303
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7304
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7305
+ * normal, healthy state of most cameras rendered as an operator disablement.
7306
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7307
+ * unconditionally by both clients, so for this row it has to read correctly
7308
+ * whichever way the switch is sitting.
7309
+ *
7310
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7311
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7312
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7313
+ * surface that decision never got.
7314
+ *
7315
+ * ## Two rules that are load-bearing
7316
+ *
7317
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7318
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7319
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7320
+ * turning the camera back on would then silently record nothing.
7321
+ * - **A switch that is off must be reported as off**, not merely produce
7322
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7323
+ * "disabled by an operator" instead of "broken" — see
7324
+ * `CameraStatus.switchedOff`.
7325
+ */
7326
+ /**
7327
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7328
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7329
+ * recognition and plate/LPR are per-step toggles on
7330
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7331
+ * editor, not in a safety group.
7332
+ */
7333
+ var CameraSwitchIdSchema = _enum([
7334
+ "stream-broker",
7335
+ "object-detection",
7336
+ "privacy-mask",
7337
+ "device-audio",
7338
+ "audio-analysis",
7339
+ "recording",
7340
+ "notifications"
7341
+ ]);
7342
+ /**
7343
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7344
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7345
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7346
+ * a compile error rather than a review comment.
7347
+ */
7348
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7349
+ object({ kind: literal("device-disabled") }),
7350
+ object({
7351
+ kind: literal("wrapper-binding"),
7352
+ capName: string()
7353
+ }),
7354
+ object({ kind: literal("recording-config") }),
7355
+ object({ kind: literal("notification-mute") }),
7356
+ object({
7357
+ kind: literal("camera-audio"),
7358
+ capName: string()
7359
+ }),
7360
+ object({
7361
+ kind: literal("camera-mask"),
7362
+ capName: string()
7363
+ })
7364
+ ]);
7365
+ /**
7366
+ * Why a switch is not offered for this camera. Rendered instead of the
7367
+ * control, never as a dead control — an absent function and a broken one must
7368
+ * not look the same.
7369
+ */
7370
+ var CameraSwitchUnavailableReasonSchema = _enum([
7371
+ "no-provider",
7372
+ "source-unreachable",
7373
+ "not-configured"
7374
+ ]);
7375
+ /**
7376
+ * One switch, resolved for one camera.
7377
+ *
7378
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7379
+ * client-side: the viewer is a separate repository that does not import
7380
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7381
+ * that will disagree with itself. Five rows per camera is nothing.
7382
+ */
7383
+ var CameraSwitchSchema = object({
7384
+ id: CameraSwitchIdSchema,
7385
+ label: string(),
7386
+ /**
7387
+ * What the operator LOSES while this is off, in one sentence. Required, not
7388
+ * optional: a switch that cannot say what it costs should not ship.
7389
+ */
7390
+ costWhenOff: string(),
7391
+ /** False = do not render a control. `unavailableReason` says why. */
7392
+ available: boolean(),
7393
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7394
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7395
+ enabled: boolean(),
7396
+ authority: CameraSwitchAuthoritySchema
7397
+ });
7398
+ /** The whole group for one camera. */
7399
+ var CameraSwitchGroupSchema = object({
7400
+ deviceId: number().int(),
7401
+ switches: array(CameraSwitchSchema).readonly(),
7402
+ /** Unix ms when the group was composed server-side. */
7403
+ fetchedAt: number()
7404
+ });
7405
+ /**
7212
7406
  * Ops-log — the durable, append-only operations audit shared by the
7213
7407
  * recordings and events management surfaces.
7214
7408
  *
@@ -7227,14 +7421,16 @@ var OpsLogOpSchema = _enum([
7227
7421
  "manual-delete",
7228
7422
  "rescan",
7229
7423
  "retention-run",
7230
- "relocate"
7424
+ "relocate",
7425
+ "orphan-audit"
7231
7426
  ]);
7232
7427
  /** Why the operation ran. */
7233
7428
  var OpsLogReasonSchema = _enum([
7234
7429
  "retention",
7235
7430
  "quota",
7236
7431
  "manual",
7237
- "operator"
7432
+ "operator",
7433
+ "maintenance"
7238
7434
  ]);
7239
7435
  /** One audit row, shared verbatim by both domains. */
7240
7436
  var OpsLogEntrySchema = object({
@@ -9168,6 +9364,126 @@ var RtpSourceSchema = object({
9168
9364
  encoder: string(),
9169
9365
  pipelineKey: string()
9170
9366
  });
9367
+ /**
9368
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9369
+ * hatch.** This is deliberate and it is the one lesson taken from
9370
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9371
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9372
+ * adding a flag silently forks the shared child, and two consumers that mean
9373
+ * the same thing but spell it differently never share. Here every knob is a
9374
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9375
+ * never an opaque array.
9376
+ *
9377
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9378
+ * The operator-facing derived-stream transform editor still has them — that is
9379
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9380
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9381
+ */
9382
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9383
+ inputArgs: true,
9384
+ outputArgs: true
9385
+ });
9386
+ /**
9387
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9388
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9389
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9390
+ * an Echo). Named rather than numeric so the INTENT survives.
9391
+ */
9392
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9393
+ var EgressTranscodeRequestSchema = object({
9394
+ deviceId: number().int().nonnegative(),
9395
+ /** Which published stream to read. */
9396
+ source: discriminatedUnion("kind", [object({
9397
+ kind: literal("profile"),
9398
+ profile: CamProfileSchema
9399
+ }), object({
9400
+ kind: literal("cam-stream"),
9401
+ camStreamId: string().min(1)
9402
+ })]),
9403
+ encode: EgressEncodeSchema,
9404
+ rateControl: EgressRateControlSchema.optional(),
9405
+ /**
9406
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9407
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9408
+ * branches. Enumerated, not free text.
9409
+ */
9410
+ bitstreamFilter: _enum([
9411
+ "dump_extra",
9412
+ "h264_mp4toannexb",
9413
+ "hevc_mp4toannexb"
9414
+ ]).optional(),
9415
+ /**
9416
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9417
+ * consumer to dial the returned url. The broker picks the id and returns it
9418
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9419
+ * sharing key is computed FROM this request.
9420
+ *
9421
+ * The url is still returned and still the contract for a transcode pinned to
9422
+ * another node. But dialling it locally costs an RTSP round trip that changes
9423
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9424
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9425
+ * repacketizer branch. The push branch — the one the derived mechanism has
9426
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9427
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9428
+ * other point.
9429
+ *
9430
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9431
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9432
+ * This is that rule for media.
9433
+ */
9434
+ publishLocally: boolean().optional(),
9435
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9436
+ /**
9437
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9438
+ * and the one that matters: the broker then resolves the backend from the
9439
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9440
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9441
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9442
+ */
9443
+ decodeHwAccel: _enum([
9444
+ "auto",
9445
+ "none",
9446
+ "videotoolbox",
9447
+ "vaapi",
9448
+ "qsv",
9449
+ "cuda"
9450
+ ]).optional(),
9451
+ /**
9452
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9453
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9454
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9455
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9456
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9457
+ */
9458
+ hostname: string().optional(),
9459
+ /** Attribution for the broker panel. Never part of the sharing key. */
9460
+ tag: string().optional()
9461
+ });
9462
+ var EgressTranscodeSchema = object({
9463
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9464
+ url: string(),
9465
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9466
+ pipelineKey: string(),
9467
+ videoCodec: _enum(["H264", "H265"]),
9468
+ resolution: object({
9469
+ width: number().int().positive(),
9470
+ height: number().int().positive()
9471
+ }),
9472
+ transcoded: boolean(),
9473
+ encoder: string(),
9474
+ /**
9475
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9476
+ * Returned rather than assumed: a consumer that asked for hardware and got
9477
+ * software needs to be able to see that without reading the broker's logs.
9478
+ */
9479
+ decodeHwAccel: string().nullable(),
9480
+ /**
9481
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9482
+ * `url`, and the session takes the push/deframe transport rather than the
9483
+ * RTP-passthrough one. `null` means the consumer must dial.
9484
+ */
9485
+ camStreamId: string().nullable()
9486
+ });
9171
9487
  method(object({
9172
9488
  deviceId: number().int().nonnegative(),
9173
9489
  camStreamId: string().min(1),
@@ -9277,6 +9593,15 @@ method(object({
9277
9593
  }), {
9278
9594
  kind: "mutation",
9279
9595
  auth: "admin"
9596
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9597
+ kind: "mutation",
9598
+ auth: "admin"
9599
+ }), method(object({ pipelineKey: string() }), object({
9600
+ released: boolean(),
9601
+ refcount: number().int().nonnegative()
9602
+ }), {
9603
+ kind: "mutation",
9604
+ auth: "admin"
9280
9605
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9281
9606
  subscriptionId: string(),
9282
9607
  maxCount: number().int().positive().default(8)
@@ -13650,12 +13975,13 @@ var NcConditionsSchema = object({
13650
13975
  * source; otherwise the subject's source must equal it. Legacy records
13651
13976
  * with no stamped source are treated as `pipeline`. The union spans both
13652
13977
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13653
- * tracks carry `sensor`.
13978
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13654
13979
  */
13655
13980
  source: _enum([
13656
13981
  "pipeline",
13657
13982
  "onboard",
13658
13983
  "sensor",
13984
+ "audio",
13659
13985
  "any"
13660
13986
  ]).optional(),
13661
13987
  /**
@@ -14231,6 +14557,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14231
14557
  }), object({ success: literal(true) }), {
14232
14558
  kind: "mutation",
14233
14559
  auth: "admin"
14560
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14561
+ deviceId: number().int(),
14562
+ muted: boolean()
14563
+ }), object({ success: literal(true) }), {
14564
+ kind: "mutation",
14565
+ auth: "admin"
14234
14566
  }), method(object({
14235
14567
  rule: NcRuleInputSchema,
14236
14568
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14575,12 +14907,60 @@ var TrackAudioLabelSchema = object({
14575
14907
  });
14576
14908
  /**
14577
14909
  * How a track was produced. `pipeline` (default / absent) = the spatial
14578
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14579
- * linked sensor/control state change (no positions; carries a snapshot). The
14580
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14581
- * resurrection) MUST skip `sensor` tracks — they have no bbox trajectory.
14910
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection —
14911
+ * no positions, a single snapshot, and no bbox trajectory at all:
14912
+ *
14913
+ * - `sensor` — a linked sensor/control device state change.
14914
+ * - `audio` — an audio event on the camera itself that was anomalous for
14915
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
14916
+ *
14917
+ * The spatial subsystems (tracker association, occupancy count, re-id /
14918
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
14919
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
14920
+ * check silently readmits every source added after it was written.
14921
+ */
14922
+ var TrackSourceSchema = _enum([
14923
+ "pipeline",
14924
+ "sensor",
14925
+ "audio"
14926
+ ]);
14927
+ /**
14928
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14929
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14930
+ * so the two surfaces cannot drift.
14931
+ *
14932
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
14933
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
14934
+ * columns existed read as absent, and a consumer that needs a boolean should say
14935
+ * `flag === true`, not `flag !== false`.
14936
+ *
14937
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14938
+ * operator curation, and the behaviour they drive will be specified separately.
14939
+ * In particular a `markForTrain` track is NOT pinned against retention — see
14940
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
14582
14941
  */
14583
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
14942
+ var TrackFlagFields = {
14943
+ /** Operator marked this track as training material. */
14944
+ markForTrain: boolean().optional(),
14945
+ /** Operator marked this track for diagnostic attention. */
14946
+ debug: boolean().optional()
14947
+ };
14948
+ /**
14949
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14950
+ * one flag can never clear the other — the toggles are independent and are
14951
+ * driven from three surfaces that do not know about each other.
14952
+ */
14953
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
14954
+ /**
14955
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
14956
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
14957
+ * mutation result without a re-fetch.
14958
+ */
14959
+ var TrackFlagsSchema = object({
14960
+ trackId: string(),
14961
+ markForTrain: boolean(),
14962
+ debug: boolean()
14963
+ });
14584
14964
  var TrackSchema = object({
14585
14965
  trackId: string(),
14586
14966
  deviceId: number(),
@@ -14623,7 +15003,8 @@ var TrackSchema = object({
14623
15003
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14624
15004
  * Populated from the persisted envelope columns on historical reads;
14625
15005
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14626
- envelope: TrackEnvelopeSchema.optional()
15006
+ envelope: TrackEnvelopeSchema.optional(),
15007
+ ...TrackFlagFields
14627
15008
  });
14628
15009
  var BaseEventFields = {
14629
15010
  id: string(),
@@ -14836,7 +15217,8 @@ var KeyEventSchema = object({
14836
15217
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
14837
15218
  bestEventId: string(),
14838
15219
  /** Track lifetime in ms (lastSeen - firstSeen). */
14839
- windowMs: number().optional()
15220
+ windowMs: number().optional(),
15221
+ ...TrackFlagFields
14840
15222
  });
14841
15223
  object({
14842
15224
  trackId: string(),
@@ -14921,16 +15303,32 @@ var RebuildObjectEmbeddingsInput = object({
14921
15303
  deviceId: number().optional(),
14922
15304
  since: number().optional(),
14923
15305
  until: number().optional(),
15306
+ /** Stop after this many tracks; the result reports whether more remain. */
15307
+ maxTracks: number().int().positive().optional(),
14924
15308
  /**
14925
- * Fraction added on EACH side of the detection box before cropping.
14926
- * ~0.2 (a 1.4x window) gives CLIP the surroundings it is trained on; 0 is
14927
- * the pixel-tight crop the first implementation used.
15309
+ * Run every embedding on THIS node instead of round-robining the fleet.
15310
+ *
15311
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15312
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15313
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15314
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15315
+ * remotely. This field is data; the per-track pin is applied inside.
15316
+ *
15317
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15318
+ * pinned model.
14928
15319
  */
14929
- cropMargin: number().min(0).max(4).optional(),
14930
- /** Square the window before extracting, so a tall subject is not squashed. */
14931
- square: boolean().optional(),
14932
- /** Stop after this many tracks; the result reports whether more remain. */
14933
- maxTracks: number().int().positive().optional()
15320
+ executeOnNodeId: string().optional(),
15321
+ /**
15322
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15323
+ * run flat out.
15324
+ *
15325
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15326
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15327
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15328
+ * force is logged at start and finish so a deliberately slow pass reads
15329
+ * differently from a stalled one.
15330
+ */
15331
+ pacingMs: number().int().nonnegative().optional()
14934
15332
  });
14935
15333
  /**
14936
15334
  * Result of emptying the CLIP index.
@@ -14963,6 +15361,24 @@ var RebuildStatusSchema = object({
14963
15361
  missingKeyFrame: number(),
14964
15362
  /** Tracks with no usable detection box. */
14965
15363
  missingBbox: number(),
15364
+ /**
15365
+ * Tracks an executing node REFUSED rather than broke on — an unreadable key
15366
+ * frame, a step that threw. Separate from `failed` because the remedy is
15367
+ * different, and because a whole camera silently contributing zero vectors
15368
+ * is the shape of failure a rebuild must never hide.
15369
+ */
15370
+ notRunnable: number(),
15371
+ /**
15372
+ * The pass stopped because NO node could serve the pinned model.
15373
+ *
15374
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15375
+ * refused", this one says "the cluster cannot do this work at all" — every
15376
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15377
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15378
+ * engine change, not a per-camera one. Non-zero here always comes with
15379
+ * `complete: false`.
15380
+ */
15381
+ noCapableNode: number(),
14966
15382
  failed: number(),
14967
15383
  /** Set once a pass ends: true only when EVERYTHING was covered. */
14968
15384
  complete: boolean().nullable(),
@@ -15034,7 +15450,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15034
15450
  }), {
15035
15451
  kind: "mutation",
15036
15452
  auth: "admin"
15037
- }), method(object({}), EventStoreFootprintSchema, {
15453
+ }), method(object({
15454
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15455
+ deviceId: number(),
15456
+ trackId: string(),
15457
+ flags: TrackFlagsPatchSchema
15458
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
15038
15459
  kind: "query",
15039
15460
  auth: "admin"
15040
15461
  }), method(object({
@@ -15644,6 +16065,53 @@ var DetailResultSchema = object({
15644
16065
  nativeFaceShortSidePx: number().optional()
15645
16066
  });
15646
16067
  /**
16068
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
16069
+ *
16070
+ * A refusal is a first-class answer, not an error, because the caller's next
16071
+ * move depends on WHICH one it is — and because "the pass produced nothing"
16072
+ * must never be reachable without a named, counted cause. The two tiers:
16073
+ *
16074
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
16075
+ * never serve this (step, model) pair. The caller drops it from its rotation
16076
+ * and retries the same work elsewhere; nothing about the work changes.
16077
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
16078
+ * fine, this one request is not. Retrying it on another node would only
16079
+ * spread the same failure.
16080
+ */
16081
+ var StatelessStepRefusalSchema = _enum([
16082
+ "unknown-step",
16083
+ "model-not-servable",
16084
+ "unreadable-frame",
16085
+ "execution-failed"
16086
+ ]);
16087
+ /**
16088
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
16089
+ * result, because `null` is exactly what made the camera-bound detail path
16090
+ * unable to tell "refused" from "never asked".
16091
+ */
16092
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
16093
+ kind: literal("ran"),
16094
+ /** The node that actually executed it — the pin, echoed back for the log. */
16095
+ nodeId: string(),
16096
+ /**
16097
+ * The model the step ran with.
16098
+ *
16099
+ * The node verified this exact id has a build for the format it dispatched
16100
+ * on BEFORE running, so the executor's format resolution returns it
16101
+ * unchanged. A caller that pinned a model must compare this field and
16102
+ * treat a mismatch as a refusal — the whole point of the pin is that a
16103
+ * pass writes one feature space.
16104
+ */
16105
+ modelId: string(),
16106
+ details: array(DetailResultSchema)
16107
+ }), object({
16108
+ kind: literal("refused"),
16109
+ nodeId: string(),
16110
+ reason: StatelessStepRefusalSchema,
16111
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
16112
+ detail: string()
16113
+ })]);
16114
+ /**
15647
16115
  * Per-camera tunable ranges + defaults. Single source of truth used
15648
16116
  * by both the Zod data schema (validation + default fallback) and
15649
16117
  * the device settings UI (slider min/max/step). Touch one place and
@@ -15979,10 +16447,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15979
16447
  }), NativeCropResultSchema.nullable()), method(object({
15980
16448
  deviceId: number(),
15981
16449
  frameHandle: FrameHandleSchema.optional(),
16450
+ /**
16451
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16452
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16453
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16454
+ */
16455
+ frameJpeg: string().optional(),
16456
+ /**
16457
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16458
+ * The fallback when the lease/session backing the frame is gone and the
16459
+ * caller already holds a crop.
16460
+ */
15982
16461
  cropJpeg: string().optional(),
15983
16462
  parent: DetailParentSchema,
15984
16463
  steps: array(string()).optional()
15985
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16464
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16465
+ /** Catalog step id, e.g. `clip-embedding`. */
16466
+ stepId: string(),
16467
+ /**
16468
+ * REQUIRED model pin. The node runs this exact model or refuses with
16469
+ * `model-not-servable` — it never substitutes a format default, because
16470
+ * a fleet pass that round-robins across nodes would then fill one index
16471
+ * from several encoders.
16472
+ */
16473
+ modelId: string(),
16474
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16475
+ frameJpeg: string(),
16476
+ /**
16477
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16478
+ * purpose: the caller stores boxes against a downscaled analysis frame
16479
+ * while the stored key frame is native-resolution, and the only side
16480
+ * that reliably knows the image's pixel dimensions is the side that
16481
+ * decodes it. Denormalising here removes a second reader of the
16482
+ * dimensions and the class of mismatch that comes with it.
16483
+ */
16484
+ bbox: NativeCropBboxSchema,
16485
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16486
+ className: string(),
16487
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16488
+ sourceDeviceId: number()
16489
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
15986
16490
  var CameraPipelineConfigSchema = object({
15987
16491
  engine: PipelineEngineChoiceSchema.optional(),
15988
16492
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16280,6 +16784,20 @@ var CameraStatusSchema = object({
16280
16784
  detection: CameraDetectionStatusSchema.nullable(),
16281
16785
  audio: CameraAudioStatusSchema.nullable(),
16282
16786
  recording: CameraRecordingStatusSchema.nullable(),
16787
+ /**
16788
+ * Per-camera function switches an OPERATOR has turned off
16789
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
16790
+ *
16791
+ * This is the difference between DISABLED and BROKEN. A camera whose
16792
+ * `detection` block reports zero fps and whose `switchedOff` contains
16793
+ * `'object-detection'` was switched off by a person; the same camera with an
16794
+ * empty list is failing. Every status surface must render the two
16795
+ * differently — a quiet camera that looks identical to a dead one is the
16796
+ * silence-reads-as-never-happened trap this repo keeps paying for.
16797
+ *
16798
+ * Empty when nothing is off. Never contains a switch no provider offers.
16799
+ */
16800
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16283
16801
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16284
16802
  fetchedAt: number()
16285
16803
  });
@@ -16448,7 +16966,14 @@ method(object({
16448
16966
  }), method(object({
16449
16967
  deviceId: number(),
16450
16968
  agentNodeId: string().optional()
16451
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16969
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
16970
+ deviceId: number(),
16971
+ switchId: CameraSwitchIdSchema,
16972
+ enabled: boolean()
16973
+ }), CameraSwitchGroupSchema, {
16974
+ kind: "mutation",
16975
+ auth: "admin"
16976
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16452
16977
  name: string(),
16453
16978
  description: string().optional(),
16454
16979
  config: CameraPipelineConfigSchema
@@ -16771,9 +17296,15 @@ DeviceType.Camera, method(object({
16771
17296
  * Bypass the cache freshness check and fetch directly from the
16772
17297
  * native (or stream-broker fallback). Triggered by the UI's
16773
17298
  * "refresh" button so an operator can force a fresh frame
16774
- * even when the cache is well within `snapshotMaxAgeMs`.
16775
- * On battery cams this WILL wake the camera — accept the
16776
- * cost only when the user explicitly asks for it.
17299
+ * even when the cache is well within the device's
17300
+ * `snapshotMaxAgeS` window.
17301
+ *
17302
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
17303
+ * battery camera it is the one thing that walks past the wrapper's
17304
+ * sleep gate and wakes the camera, so a background caller — a poller,
17305
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
17306
+ * gets the cached frame, which on a sleeping battery camera is the
17307
+ * correct answer: stale but honest beats woken.
16777
17308
  */
16778
17309
  force: boolean().optional()
16779
17310
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -21055,12 +21586,30 @@ object({
21055
21586
  });
21056
21587
  DeviceType.Sensor;
21057
21588
  /**
21058
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
21059
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
21060
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
21061
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
21062
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
21063
- * normalized 0..1 (top-left origin).
21589
+ * PRIVACY — what the camera deliberately does not capture. Two planes:
21590
+ *
21591
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
21592
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
21593
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
21594
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
21595
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
21596
+ * normalized 0..1 (top-left origin).
21597
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
21598
+ * camera encoding an audio track at all, so EVERY consumer — live view,
21599
+ * recording, the audio analyzer, an export — sees silent video. There is
21600
+ * no server-side copy of this fact; the camera is the store and every read
21601
+ * is a read-through, which is why a switch over it cannot drift
21602
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
21603
+ *
21604
+ * Both belong here for one reason: they are the two things an operator turns
21605
+ * off when the answer to "what is this camera allowed to record" changes, and
21606
+ * both are applied ON the device, before anything leaves it.
21607
+ *
21608
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
21609
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
21610
+ * by one provider — and it was removed when this landed. A second writer onto
21611
+ * one device register is the shape of every knob this repo has shipped that
21612
+ * disagreed with the one the reader read.
21064
21613
  */
21065
21614
  /** A privacy-mask region's geometry — rectangle or free polygon. */
21066
21615
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -21076,16 +21625,40 @@ object({
21076
21625
  enabled: boolean(),
21077
21626
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
21078
21627
  regions: array(PrivacyMaskRegionSchema),
21628
+ /**
21629
+ * Is the camera capturing sound right now? Read from the camera, never from
21630
+ * a server-side mirror.
21631
+ *
21632
+ * `null` means "no answer" — either this camera exposes no controllable
21633
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
21634
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
21635
+ * "the microphone is off" and "we could not ask" look identical to an
21636
+ * operator only until one of them is wrong.
21637
+ *
21638
+ * On a camera whose profiles carry the flag independently (Reolink writes
21639
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
21640
+ * privacy is only satisfied when every one of them is silent.
21641
+ */
21642
+ audioEnabled: boolean().nullable(),
21079
21643
  lastFetchedAt: number()
21080
21644
  });
21081
- /** Per-camera availability. */
21645
+ /** Per-camera availability. Probed, never assumed from the model name. */
21082
21646
  var PrivacyMaskOptionsSchema = object({
21083
21647
  /** Maximum number of supported zones. */
21084
21648
  maxRegions: number(),
21085
21649
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
21086
21650
  supportedShapes: array(MaskShapeKindSchema),
21087
21651
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
21088
- polygonVertices: MaskPolygonVerticesSchema.optional()
21652
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
21653
+ /**
21654
+ * Does this camera expose a microphone switch we can actually write?
21655
+ *
21656
+ * Camera-probed: `true` only when the firmware answered with an audio flag
21657
+ * we know how to patch. A camera that never answered is `false` — a control
21658
+ * the operator can press that changes nothing is worse than no control, and
21659
+ * the switch group renders "not available" instead.
21660
+ */
21661
+ supportsAudioMute: boolean()
21089
21662
  });
21090
21663
  /** Partial change — every field optional. */
21091
21664
  var PrivacyMaskPatchSchema = object({
@@ -21098,6 +21671,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
21098
21671
  }), _void(), {
21099
21672
  kind: "mutation",
21100
21673
  auth: "admin"
21674
+ }), method(object({
21675
+ deviceId: number(),
21676
+ enabled: boolean()
21677
+ }), _void(), {
21678
+ kind: "mutation",
21679
+ auth: "admin"
21101
21680
  });
21102
21681
  var PtzPresetSchema = object({
21103
21682
  id: string(),
@@ -21307,6 +21886,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
21307
21886
  })]);
21308
21887
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
21309
21888
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
21889
+ /**
21890
+ * One GOP of a finalized segment, cut by byte range through the segment's own
21891
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
21892
+ * the single `moof`+`mdat` covering the requested instant — standalone-
21893
+ * demuxable, never the whole file. When the segment's index cannot be parsed
21894
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
21895
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
21896
+ */
21897
+ var ReadGopBytesResultSchema = object({
21898
+ data: _instanceof(Uint8Array),
21899
+ /** Absolute epoch ms of the returned fragment's first sample. */
21900
+ gopStartMs: number(),
21901
+ /** Media ms the returned fragment covers. */
21902
+ gopDurMs: number()
21903
+ });
21310
21904
  method(object({
21311
21905
  deviceId: number(),
21312
21906
  fromMs: number(),
@@ -21349,6 +21943,14 @@ method(object({
21349
21943
  }), ReadSegmentBytesResultSchema, {
21350
21944
  kind: "query",
21351
21945
  auth: "admin"
21946
+ }), method(object({
21947
+ deviceId: number(),
21948
+ profile: string(),
21949
+ startMs: number(),
21950
+ epochMs: number()
21951
+ }), ReadGopBytesResultSchema, {
21952
+ kind: "query",
21953
+ auth: "admin"
21352
21954
  }), method(object({
21353
21955
  deviceId: number(),
21354
21956
  config: RecordingConfigSchema
@@ -21829,6 +22431,16 @@ var StreamProfileConfigSchema = object({
21829
22431
  "baseline"
21830
22432
  ]).optional(),
21831
22433
  gop: number().optional(),
22434
+ /**
22435
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
22436
+ *
22437
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
22438
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
22439
+ * writes every profile at once so "audio off" means silent everywhere. A
22440
+ * per-profile writer beside it would let a camera be half-muted and would be
22441
+ * a second knob onto one device register — the failure D62 exists to
22442
+ * prevent. Absent when the firmware does not report the flag.
22443
+ */
21832
22444
  audio: boolean().optional()
21833
22445
  });
21834
22446
  object({
@@ -21869,7 +22481,13 @@ var StreamParamsOptionsSchema = object({
21869
22481
  ext: StreamProfileOptionsSchema.optional()
21870
22482
  });
21871
22483
  /** A partial change to one profile — every field optional; a provider
21872
- * ignores fields it doesn't support. */
22484
+ * ignores fields it doesn't support.
22485
+ *
22486
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
22487
+ * reachable from no form and honoured by exactly one provider, while the
22488
+ * camera's microphone is a whole-device fact. It now has one writer,
22489
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
22490
+ * `privacy-mask.cap.ts`. */
21873
22491
  var StreamProfilePatchSchema = object({
21874
22492
  width: number().optional(),
21875
22493
  height: number().optional(),
@@ -21882,8 +22500,7 @@ var StreamProfilePatchSchema = object({
21882
22500
  "main",
21883
22501
  "baseline"
21884
22502
  ]).optional(),
21885
- gop: number().optional(),
21886
- audio: boolean().optional()
22503
+ gop: number().optional()
21887
22504
  });
21888
22505
  DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
21889
22506
  deviceId: number(),
@@ -25405,6 +26022,12 @@ Object.freeze({
25405
26022
  addonId: null,
25406
26023
  access: "view"
25407
26024
  },
26025
+ "notificationRules.listDeviceMutes": {
26026
+ capName: "notification-rules",
26027
+ capScope: "system",
26028
+ addonId: null,
26029
+ access: "view"
26030
+ },
25408
26031
  "notificationRules.listRules": {
25409
26032
  capName: "notification-rules",
25410
26033
  capScope: "system",
@@ -25423,6 +26046,12 @@ Object.freeze({
25423
26046
  addonId: null,
25424
26047
  access: "create"
25425
26048
  },
26049
+ "notificationRules.setDeviceMuted": {
26050
+ capName: "notification-rules",
26051
+ capScope: "system",
26052
+ addonId: null,
26053
+ access: "create"
26054
+ },
25426
26055
  "notificationRules.setRuleEnabled": {
25427
26056
  capName: "notification-rules",
25428
26057
  capScope: "system",
@@ -25693,11 +26322,17 @@ Object.freeze({
25693
26322
  addonId: null,
25694
26323
  access: "create"
25695
26324
  },
25696
- "pipelineAnalytics.searchObjectEvents": {
26325
+ "pipelineAnalytics.searchObjectEvents": {
26326
+ capName: "pipeline-analytics",
26327
+ capScope: "device",
26328
+ addonId: null,
26329
+ access: "view"
26330
+ },
26331
+ "pipelineAnalytics.setTrackFlags": {
25697
26332
  capName: "pipeline-analytics",
25698
26333
  capScope: "device",
25699
26334
  addonId: null,
25700
- access: "view"
26335
+ access: "create"
25701
26336
  },
25702
26337
  "pipelineAnalytics.wipeAllAnalytics": {
25703
26338
  capName: "pipeline-analytics",
@@ -26005,6 +26640,12 @@ Object.freeze({
26005
26640
  addonId: null,
26006
26641
  access: "view"
26007
26642
  },
26643
+ "pipelineOrchestrator.getCameraSwitches": {
26644
+ capName: "pipeline-orchestrator",
26645
+ capScope: "system",
26646
+ addonId: null,
26647
+ access: "view"
26648
+ },
26008
26649
  "pipelineOrchestrator.getCapabilityBindings": {
26009
26650
  capName: "pipeline-orchestrator",
26010
26651
  capScope: "system",
@@ -26137,6 +26778,12 @@ Object.freeze({
26137
26778
  addonId: null,
26138
26779
  access: "create"
26139
26780
  },
26781
+ "pipelineOrchestrator.setCameraSwitch": {
26782
+ capName: "pipeline-orchestrator",
26783
+ capScope: "system",
26784
+ addonId: null,
26785
+ access: "create"
26786
+ },
26140
26787
  "pipelineOrchestrator.setCapabilityBinding": {
26141
26788
  capName: "pipeline-orchestrator",
26142
26789
  capScope: "system",
@@ -26227,6 +26874,12 @@ Object.freeze({
26227
26874
  addonId: null,
26228
26875
  access: "create"
26229
26876
  },
26877
+ "pipelineRunner.runStatelessStep": {
26878
+ capName: "pipeline-runner",
26879
+ capScope: "system",
26880
+ addonId: null,
26881
+ access: "create"
26882
+ },
26230
26883
  "plateGallery.assignPlate": {
26231
26884
  capName: "plate-gallery",
26232
26885
  capScope: "system",
@@ -26359,6 +27012,12 @@ Object.freeze({
26359
27012
  addonId: null,
26360
27013
  access: "view"
26361
27014
  },
27015
+ "privacyMask.setAudioEnabled": {
27016
+ capName: "privacy-mask",
27017
+ capScope: "device",
27018
+ addonId: null,
27019
+ access: "create"
27020
+ },
26362
27021
  "privacyMask.setMask": {
26363
27022
  capName: "privacy-mask",
26364
27023
  capScope: "device",
@@ -26527,6 +27186,12 @@ Object.freeze({
26527
27186
  addonId: null,
26528
27187
  access: "create"
26529
27188
  },
27189
+ "recording.readGopBytes": {
27190
+ capName: "recording",
27191
+ capScope: "system",
27192
+ addonId: null,
27193
+ access: "view"
27194
+ },
26530
27195
  "recording.readSegmentBytes": {
26531
27196
  capName: "recording",
26532
27197
  capScope: "system",
@@ -27043,6 +27708,12 @@ Object.freeze({
27043
27708
  addonId: null,
27044
27709
  access: "create"
27045
27710
  },
27711
+ "streamBroker.acquireEgressTranscode": {
27712
+ capName: "stream-broker",
27713
+ capScope: "system",
27714
+ addonId: null,
27715
+ access: "create"
27716
+ },
27046
27717
  "streamBroker.assignProfile": {
27047
27718
  capName: "stream-broker",
27048
27719
  capScope: "system",
@@ -27151,6 +27822,12 @@ Object.freeze({
27151
27822
  addonId: null,
27152
27823
  access: "create"
27153
27824
  },
27825
+ "streamBroker.releaseEgressTranscode": {
27826
+ capName: "stream-broker",
27827
+ capScope: "system",
27828
+ addonId: null,
27829
+ access: "create"
27830
+ },
27154
27831
  "streamBroker.releaseStreamWithCodec": {
27155
27832
  capName: "stream-broker",
27156
27833
  capScope: "system",
@@ -27911,6 +28588,112 @@ TimelapseRuleInputSchema.extend({
27911
28588
  createdAt: number(),
27912
28589
  updatedAt: number()
27913
28590
  });
28591
+ object({
28592
+ /**
28593
+ * Fraction of the box's own size added on EACH side before cutting.
28594
+ *
28595
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
28596
+ * removes exactly the context it is strongest on (a dog cut to its outline
28597
+ * is a dark blob). The right value is an empirical question, which is why it
28598
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
28599
+ */
28600
+ paddingRatio: number().min(0).max(4),
28601
+ /**
28602
+ * Square the window (in PIXELS) before cutting.
28603
+ *
28604
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
28605
+ * — a standing person becomes a shape the model never saw. Squaring costs
28606
+ * extra background, which is context the model wants anyway. Off by default
28607
+ * because the live path has never squared and the stored index reflects that.
28608
+ */
28609
+ square: boolean()
28610
+ });
28611
+ ({
28612
+ paddingRatio: .15,
28613
+ square: false
28614
+ }).paddingRatio;
28615
+ /**
28616
+ * WHICH delivered frames the decode worker retains a native copy of.
28617
+ *
28618
+ * - `all` — every frame the worker delivered to the runner. The shipped
28619
+ * behaviour, and the only correct one if something can ask for a crop of a
28620
+ * frame the runner never sent to inference.
28621
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
28622
+ * A native-crop request always names a `frameId` that rode an inference
28623
+ * result, so that is the only set a request can name. How much it drops is
28624
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
28625
+ * this cluster, not the ~80% the design sketch assumed, because the governor
28626
+ * was not throttling as hard as the sketch supposed. Read
28627
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
28628
+ * of you rather than quoting a number from here. The newest delivered frame is
28629
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
28630
+ * which covers the one-frame race between a mark and the supersede that
28631
+ * consumes it.
28632
+ */
28633
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
28634
+ object({
28635
+ /**
28636
+ * How long a retained native frame is served before it counts as a miss.
28637
+ *
28638
+ * Must cover the FULL late-crop horizon: detection inference + the
28639
+ * cross-process inference-result hop to hub post-analysis + tracking + the
28640
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
28641
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
28642
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
28643
+ */
28644
+ ttlMs: number().int().min(250).max(1e4),
28645
+ /**
28646
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
28647
+ *
28648
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
28649
+ * which one is actually binding before reasoning from that. At the shipped
28650
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
28651
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
28652
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
28653
+ * change that admits fewer frames buys retention WINDOW at constant RAM
28654
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
28655
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
28656
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
28657
+ * to replace).
28658
+ */
28659
+ budgetMb: number().int().min(0).max(4096),
28660
+ /**
28661
+ * Demand window: eager per-frame native retention runs only within this many
28662
+ * ms of the last native-crop request (or of the dial starting).
28663
+ *
28664
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
28665
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
28666
+ * per second on a camera with zero crop demand), so leave it non-zero unless
28667
+ * you are reproducing that.
28668
+ */
28669
+ activityMs: number().int().min(0).max(12e4),
28670
+ /**
28671
+ * Which delivered frames are retained at all — see
28672
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
28673
+ * changes WHAT is kept rather than for how long, so it is also the only one
28674
+ * that can turn a crop that used to hit into a miss. The worker counts every
28675
+ * crop request naming a frame it did NOT see marked
28676
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
28677
+ * there is the signal that some caller names frames outside the inference set
28678
+ * and that this must go back to `all`.
28679
+ */
28680
+ admission: NativeLeaseAdmissionSchema
28681
+ });
28682
+ /**
28683
+ * The values in force when the operator has set nothing — byte-for-byte the
28684
+ * constants the decode worker shipped with as env-var defaults, so making these
28685
+ * settings changed no behaviour on the day it landed.
28686
+ */
28687
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
28688
+ ttlMs: 1200,
28689
+ budgetMb: 1024,
28690
+ activityMs: 15e3,
28691
+ admission: "inferred"
28692
+ };
28693
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
28694
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
28695
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
28696
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
27914
28697
  //#endregion
27915
28698
  //#region src/custom-actions.ts
27916
28699
  /**
@@ -27942,6 +28725,87 @@ var exportAlexaActions = defineCustomActions({
27942
28725
  }), { kind: "mutation" })
27943
28726
  });
27944
28727
  //#endregion
28728
+ //#region src/egress/alexa-rtc-egress.ts
28729
+ /**
28730
+ * Acquire Alexa's encoded stream and make it dialable by a WebRTC session.
28731
+ *
28732
+ * The request carries NO raw ffmpeg flags — the primitive's schema has no
28733
+ * escape hatch, on purpose: an opaque `outputArgs` array would be both the
28734
+ * extensibility mechanism AND part of the sharing key, so one extra flag would
28735
+ * silently fork the shared child and two consumers meaning the same thing but
28736
+ * spelling it differently would never share.
28737
+ *
28738
+ * Placement note: the call is UNPINNED, so `classifyCapRoute` serves it
28739
+ * hub-in-process. Moving Alexa's transcode to another node is a `nodePin` here
28740
+ * plus a `hostname` this addon can dial — nothing else in this function
28741
+ * assumes hub-locality.
28742
+ */
28743
+ async function acquireAlexaEgress(input) {
28744
+ const { api, deviceId, sourceProfile, sessionId } = input;
28745
+ const transcode = await api.streamBroker.acquireEgressTranscode.mutate({
28746
+ deviceId,
28747
+ source: {
28748
+ kind: "profile",
28749
+ profile: sourceProfile
28750
+ },
28751
+ encode: ALEXA_EGRESS_PROFILE,
28752
+ rateControl: "relaxed",
28753
+ publishLocally: true,
28754
+ tag: `alexa/${sessionId}`
28755
+ });
28756
+ const camStreamId = transcode.camStreamId;
28757
+ if (camStreamId === null) {
28758
+ await api.streamBroker.releaseEgressTranscode.mutate({ pipelineKey: transcode.pipelineKey }).catch(() => void 0);
28759
+ input.logger?.warn("alexa egress: broker did not publish locally — released the handle", {
28760
+ tags: {
28761
+ deviceId,
28762
+ sessionId
28763
+ },
28764
+ meta: { pipelineKey: transcode.pipelineKey }
28765
+ });
28766
+ throw new Error("alexa egress: broker declined to publish the transcode locally");
28767
+ }
28768
+ input.logger?.info("alexa egress: acquired through the ffmpeg primitive", {
28769
+ tags: {
28770
+ deviceId,
28771
+ sessionId
28772
+ },
28773
+ meta: {
28774
+ pipelineKey: transcode.pipelineKey,
28775
+ camStreamId,
28776
+ encoder: transcode.encoder,
28777
+ decodeHwAccel: transcode.decodeHwAccel ?? "software"
28778
+ }
28779
+ });
28780
+ return {
28781
+ camStreamId,
28782
+ pipelineKey: transcode.pipelineKey,
28783
+ transcode
28784
+ };
28785
+ }
28786
+ /**
28787
+ * Drop this session's reference, and retract the published stream once the LAST
28788
+ * holder has gone.
28789
+ *
28790
+ * Never throws. Alexa expects an `Alexa.Response` to `SessionDisconnected`, and
28791
+ * a stale handle after a broker respawn is a normal event — turning either into
28792
+ * an error envelope would make a clean teardown look like a fault.
28793
+ */
28794
+ async function releaseAlexaEgress(input) {
28795
+ const { api, deviceId, pipelineKey } = input;
28796
+ try {
28797
+ await api.streamBroker.releaseEgressTranscode.mutate({ pipelineKey });
28798
+ } catch (err) {
28799
+ input.logger?.warn("alexa egress: release failed — the handle may leak until the broker restarts", {
28800
+ tags: { deviceId },
28801
+ meta: {
28802
+ pipelineKey,
28803
+ error: errMsg(err)
28804
+ }
28805
+ });
28806
+ }
28807
+ }
28808
+ //#endregion
27945
28809
  //#region src/handlers/types.ts
27946
28810
  /**
27947
28811
  * Zod schemas for the subset of the Alexa Smart Home directive surface
@@ -28655,169 +29519,7 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
28655
29519
  } };
28656
29520
  }
28657
29521
  //#endregion
28658
- //#region src/egress/alexa-rtc-egress.ts
28659
- /**
28660
- * `AlexaRtcEgress` — placeholder `RtpEgress` adapter for the Alexa RTC
28661
- * session path.
28662
- *
28663
- * Today Alexa RTC works by handing the SDP offer received in the
28664
- * `Alexa.RTCSessionController.InitiateSessionWithOffer` directive to the
28665
- * `webrtc-session` cap's `handleOffer` mutation. That call lives inside
28666
- * `addon-pipeline`, runs through the broker-resident `AdaptiveSession`,
28667
- * and ultimately wires the same DTLS-SRTP-over-werift egress as the
28668
- * browser viewer. The Alexa path is therefore "WebRTC over a different
28669
- * signalling channel" — the egress is functionally `WebRtcEgress`.
28670
- *
28671
- * This wrapper exists for two reasons:
28672
- * 1. So the four export targets (WebRTC viewer, HomeKit, Alexa, future
28673
- * SRTP-out) each have an `RtpEgress`-typed entry point. The fan-out
28674
- * loop in the broker is then symmetric — every consumer is "an
28675
- * `RtpEgress` someone started".
28676
- * 2. So the typechecker can keep us honest as the shared-transcode
28677
- * refactor lands. Once the broker exposes a stream-source that fan-
28678
- * outs to N egresses, the Alexa handler will switch from "post the
28679
- * offer to webrtcSession.handleOffer" to "build a local
28680
- * AdaptiveSession + register an `AlexaRtcEgress` with the broker".
28681
- *
28682
- * The class itself is therefore intentionally minimal: it accepts the
28683
- * `webrtc-session` session handle from the handler, exposes the
28684
- * RtpEgress contract, and delegates pushVideo/pushAudio to a no-op. The
28685
- * real RTP flow remains owned by the AdaptiveSession the cap created
28686
- * for us — none of the bytes flow through this object today.
28687
- *
28688
- * Derived-stream helpers
28689
- * ─────────────────────
28690
- * `computeAlexaDerivedName` and `getOrSpawnAlexaDerivedBroker` (exported
28691
- * below) implement the derived-broker spawn path. The serve path
28692
- * MUST call `getOrSpawnAlexaDerivedBroker` instead of reaching the broker
28693
- * directly so that identical EncodeProfile params share a single ffmpeg
28694
- * instance (single-flight via a deterministic brokerId).
28695
- *
28696
- */
28697
- /**
28698
- * Wrapper around an already-negotiated `webrtc-session` (created by the
28699
- * Alexa directive handler via `webrtcSession.handleOffer`). The
28700
- * AdaptiveSession owns the RTP pump, so this adapter is a no-op for
28701
- * `pushVideoRtp` / `pushAudioRtp`. The `stop()` method calls back into
28702
- * the directive handler so the underlying cap session is torn down
28703
- * when an upstream consumer disposes the egress.
28704
- */
28705
- var AlexaRtcEgress = class {
28706
- id;
28707
- logger;
28708
- closeSessionRemote;
28709
- started = false;
28710
- stopped = false;
28711
- constructor(opts) {
28712
- this.id = opts.id;
28713
- this.logger = opts.logger;
28714
- this.closeSessionRemote = opts.closeSessionRemote;
28715
- }
28716
- async start(opts) {
28717
- if (this.started) return;
28718
- if (this.stopped) throw new Error("AlexaRtcEgress: cannot restart after stop()");
28719
- this.started = true;
28720
- this.logger.debug("AlexaRtcEgress started", { meta: { egressId: this.id } });
28721
- }
28722
- pushVideoRtp(rtpPacket) {}
28723
- pushAudioRtp(rtpPacket) {}
28724
- async stop() {
28725
- if (this.stopped) return;
28726
- this.stopped = true;
28727
- this.started = false;
28728
- try {
28729
- await this.closeSessionRemote();
28730
- } catch (err) {
28731
- this.logger.warn("AlexaRtcEgress closeSessionRemote threw", { meta: {
28732
- egressId: this.id,
28733
- error: errMsg(err)
28734
- } });
28735
- }
28736
- }
28737
- };
28738
- /**
28739
- * Derives a deterministic, human-readable name for an Alexa derived
28740
- * stream from the device id + encode profile. Identical params always
28741
- * produce the same name so the broker can reuse a running instance.
28742
- *
28743
- * Format: `alexa-<deviceId>-<sha1[0..8]>`
28744
- */
28745
- function computeAlexaDerivedName(deviceId, profile) {
28746
- return `alexa-${deviceId}-${(0, node_crypto.createHash)("sha1").update(JSON.stringify(profile)).digest("hex").slice(0, 8)}`;
28747
- }
28748
- /**
28749
- * The single encode profile used for every Alexa derived stream. The
28750
- * operator-facing encode-profile config was removed, so this constant is
28751
- * the sole profile passed to `getOrSpawnAlexaDerivedBroker`. Alexa requires
28752
- * Opus audio; H.264 baseline keeps compatibility with Echo Show hardware
28753
- * decoders.
28754
- *
28755
- * Intentionally duplicated here so `addon-export-alexa` has no dependency
28756
- * on any other addon's preset registry (cross-addon imports are forbidden).
28757
- * These values mirror the `alexa-baseline` preset in the stream-broker; the
28758
- * broker-side preset is authoritative for broker scheduling while this
28759
- * constant is the local fallback for the Alexa egress path only.
28760
- */
28761
- var ALEXA_FALLBACK_ENCODE_PROFILE = {
28762
- video: {
28763
- codec: "h264",
28764
- profile: "baseline",
28765
- width: 1280,
28766
- height: 720,
28767
- fps: 25,
28768
- bitrateKbps: 2500,
28769
- gopFrames: 25,
28770
- bf: 0,
28771
- preset: "veryfast",
28772
- tune: "zerolatency"
28773
- },
28774
- audio: {
28775
- codec: "opus",
28776
- bitrateKbps: 64,
28777
- sampleRateHz: 48e3,
28778
- channels: 1
28779
- }
28780
- };
28781
- /**
28782
- * Resolves the source camStreamId for the requested profile slot, then
28783
- * calls `publishCameraStream({ kind: 'derived', … })` on the broker.
28784
- *
28785
- * The broker is idempotent for identical `(camStreamId, encodeProfile)`
28786
- * pairs, so calling this multiple times for the same session is safe.
28787
- *
28788
- * Throws `ALEXA_NO_SOURCE_FOR_PROFILE` if the slot has no source assigned.
28789
- */
28790
- async function getOrSpawnAlexaDerivedBroker(opts) {
28791
- const slot = (await opts.api.streamBroker.listAllProfileSlots.query()).find((s) => s.deviceId === opts.deviceId && s.profile === opts.sourceProfile);
28792
- if (slot?.sourceCamStreamId == null) throw new Error(`ALEXA_NO_SOURCE_FOR_PROFILE: profile=${opts.sourceProfile} deviceId=${opts.deviceId}`);
28793
- const camStreamId = `derived:${computeAlexaDerivedName(opts.deviceId, opts.encodeProfile)}`;
28794
- await opts.api.streamBroker.publishCameraStream.mutate({
28795
- deviceId: opts.deviceId,
28796
- camStreamId,
28797
- kind: "derived",
28798
- sourceCamStreamId: slot.sourceCamStreamId,
28799
- encodeProfile: opts.encodeProfile
28800
- });
28801
- return { brokerId: makeSourceBrokerId(opts.deviceId, camStreamId) };
28802
- }
28803
- //#endregion
28804
29522
  //#region src/handlers/rtc-session.ts
28805
- /**
28806
- * `Alexa.RTCSessionController` handler.
28807
- *
28808
- * Alexa's flow is "client (Echo / Alexa Cloud) sends the SDP offer →
28809
- * skill returns the answer", the dual of our default `createSession`
28810
- * flow (server offers first). The `webrtc-session` device cap exposes
28811
- * `handleOffer({ deviceId, sdpOffer, sessionId? })` for exactly this
28812
- * direction; this dispatcher just translates Alexa's directive
28813
- * envelope to/from the cap call.
28814
- *
28815
- * Echo / Alexa Cloud will send `InitiateSessionWithOffer` when an
28816
- * RTSP fallback isn't preferred (Show 10/15, voice "show me"
28817
- * commands). On disconnect, Alexa sends `SessionDisconnected` and we
28818
- * tear the session down via `closeSession` so the broker stops
28819
- * forwarding frames.
28820
- */
28821
29523
  var InitiateSessionWithOfferDirectiveSchema = object({
28822
29524
  endpoint: EndpointRefSchema,
28823
29525
  payload: InitiateSessionWithOfferPayloadSchema
@@ -28826,6 +29528,23 @@ var SessionDisconnectedDirectiveSchema = object({
28826
29528
  endpoint: EndpointRefSchema,
28827
29529
  payload: SessionDisconnectedPayloadSchema
28828
29530
  });
29531
+ /**
29532
+ * Live egress handles, keyed by `<deviceId>/<sessionId>`.
29533
+ *
29534
+ * The primitive's refcount has no lease TTL: a holder that vanishes without
29535
+ * releasing leaks a child until the broker restarts (D67). This handler is a
29536
+ * holder, so it keeps the one piece of state that lets it not be that holder —
29537
+ * the `pipelineKey` its `SessionDisconnected` has to release.
29538
+ *
29539
+ * A module-level map is right here because the directive handlers are free
29540
+ * functions called per directive; the entry is written on a successful acquire
29541
+ * and deleted on the release path, both of which are the same tick as the
29542
+ * directive that owns it.
29543
+ */
29544
+ var EGRESS_HANDLES = /* @__PURE__ */ new Map();
29545
+ function egressHandleKey(deviceId, sessionId) {
29546
+ return `${deviceId}/${sessionId}`;
29547
+ }
28829
29548
  var WEBRTC_PROFILE_NAMES = [
28830
29549
  "high",
28831
29550
  "mid",
@@ -28868,7 +29587,6 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28868
29587
  if (pref === "low" || pref === "mid" || pref === "high") return pref;
28869
29588
  })();
28870
29589
  const sourceProfile = explicitSourceProfile ?? mappedFromPreference ?? "high";
28871
- const encodeProfile = ALEXA_FALLBACK_ENCODE_PROFILE;
28872
29590
  try {
28873
29591
  if (isSleepingBatteryStatus(null)) {
28874
29592
  const wakeStart = Date.now();
@@ -28892,6 +29610,13 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28892
29610
  return value["sleeping"] === true;
28893
29611
  }
28894
29612
  let derivedTarget;
29613
+ /**
29614
+ * The primitive's release handle, when this session acquired one. Recorded so
29615
+ * `SessionDisconnected` can release it: a refcount whose holder disappears is
29616
+ * the one gap D67 leaves open, and the least this handler can do is not BE
29617
+ * that holder.
29618
+ */
29619
+ let egressPipelineKey = null;
28895
29620
  const picked = await api.cameraStreams.pickStream.query({
28896
29621
  deviceId,
28897
29622
  requirements: {
@@ -28915,18 +29640,21 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28915
29640
  camStreamId: picked.camStreamId
28916
29641
  };
28917
29642
  } else try {
28918
- const { brokerId } = await getOrSpawnAlexaDerivedBroker({
29643
+ const acquired = await acquireAlexaEgress({
28919
29644
  api,
28920
29645
  deviceId,
28921
29646
  sourceProfile,
28922
- encodeProfile
29647
+ sessionId,
29648
+ logger: log
28923
29649
  });
29650
+ egressPipelineKey = acquired.pipelineKey;
29651
+ EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
28924
29652
  derivedTarget = {
28925
29653
  kind: "cam-stream",
28926
- camStreamId: brokerId.split("/").slice(1).join("/")
29654
+ camStreamId: acquired.camStreamId
28927
29655
  };
28928
29656
  } catch (err) {
28929
- log.warn("alexa rtc-session: derived spawn failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
29657
+ log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
28930
29658
  derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
28931
29659
  }
28932
29660
  try {
@@ -28993,6 +29721,15 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28993
29721
  mSections: mSectionSummary,
28994
29722
  offerSdp: sdpOffer
28995
29723
  } });
29724
+ if (egressPipelineKey !== null) {
29725
+ EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
29726
+ await releaseAlexaEgress({
29727
+ api,
29728
+ deviceId,
29729
+ pipelineKey: egressPipelineKey,
29730
+ logger: log
29731
+ });
29732
+ }
28996
29733
  return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
28997
29734
  }
28998
29735
  }
@@ -29004,19 +29741,32 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
29004
29741
  if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
29005
29742
  const endpoint = parsed.data.endpoint;
29006
29743
  const parsedId = parseCameraEndpointId(endpoint.endpointId);
29007
- if (parsedId.kind === "camera") try {
29008
- await deps.ctx.api.webrtcSession.closeSession.mutate({
29009
- deviceId: parsedId.deviceId,
29010
- sessionId: parsed.data.payload.sessionId
29011
- });
29012
- } catch (err) {
29013
- deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
29014
- tags: {
29744
+ if (parsedId.kind === "camera") {
29745
+ const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
29746
+ const pipelineKey = EGRESS_HANDLES.get(handleKey);
29747
+ if (pipelineKey !== void 0) {
29748
+ EGRESS_HANDLES.delete(handleKey);
29749
+ await releaseAlexaEgress({
29750
+ api: deps.ctx.api,
29751
+ deviceId: parsedId.deviceId,
29752
+ pipelineKey,
29753
+ logger: deps.logger
29754
+ });
29755
+ }
29756
+ try {
29757
+ await deps.ctx.api.webrtcSession.closeSession.mutate({
29015
29758
  deviceId: parsedId.deviceId,
29016
29759
  sessionId: parsed.data.payload.sessionId
29017
- },
29018
- meta: { error: err instanceof Error ? err.message : String(err) }
29019
- });
29760
+ });
29761
+ } catch (err) {
29762
+ deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
29763
+ tags: {
29764
+ deviceId: parsedId.deviceId,
29765
+ sessionId: parsed.data.payload.sessionId
29766
+ },
29767
+ meta: { error: err instanceof Error ? err.message : String(err) }
29768
+ });
29769
+ }
29020
29770
  }
29021
29771
  const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
29022
29772
  deps.logger.info("alexa rtc-session: SessionDisconnected", {
@@ -29954,6 +30704,89 @@ async function handleDirective(request, reply, deps) {
29954
30704
  reply.code(result.status).send(result.body);
29955
30705
  }
29956
30706
  //#endregion
30707
+ //#region src/motion-clear-hold.ts
30708
+ /**
30709
+ * Default hold. Matches `RESET_DEBOUNCE_MS` in
30710
+ * `addon-export-hap/src/mappers/builders/motion-sensor.ts` — the same problem
30711
+ * (a source that never reliably clears, or clears too eagerly) already has an
30712
+ * answer in this repo and a second number would only invite them to drift.
30713
+ */
30714
+ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
30715
+ var MotionClearHold = class {
30716
+ entries = /* @__PURE__ */ new Map();
30717
+ holdMs;
30718
+ setTimer;
30719
+ clearTimer;
30720
+ constructor(options) {
30721
+ this.holdMs = options.holdMs;
30722
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
30723
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
30724
+ }
30725
+ /**
30726
+ * The source reports motion.
30727
+ *
30728
+ * @returns `true` when Alexa must be told (it currently believes
30729
+ * NOT_DETECTED). `false` when Alexa already believes DETECTED — including
30730
+ * the case where a clear was pending, which this call cancels.
30731
+ */
30732
+ onDetected(key) {
30733
+ const entry = this.entries.get(key);
30734
+ if (entry?.state === "detected") return false;
30735
+ if (entry?.state === "holding") {
30736
+ if (entry.timer !== null) this.clearTimer(entry.timer);
30737
+ this.entries.set(key, {
30738
+ state: "detected",
30739
+ timer: null
30740
+ });
30741
+ return false;
30742
+ }
30743
+ this.entries.set(key, {
30744
+ state: "detected",
30745
+ timer: null
30746
+ });
30747
+ return true;
30748
+ }
30749
+ /**
30750
+ * The source reports no motion. Arms the hold; `release` runs once the
30751
+ * source has stayed clear for `holdMs`. A no-op when we KNOW Alexa already
30752
+ * believes NOT_DETECTED, or when a clear is already pending.
30753
+ *
30754
+ * An UNKNOWN key arms the hold rather than returning early, and that is the
30755
+ * whole reason this branch is spelled out. This map is in-memory: after an
30756
+ * addon restart it is empty, while Alexa's copy of `detectionState` is
30757
+ * whatever we last told it. If the addon respawned during a motion — which a
30758
+ * `camstack deploy` does routinely — treating "unknown" as "already clear"
30759
+ * leaves the Alexa app showing motion until that camera happens to trigger
30760
+ * again, which on a quiet camera is hours. The cost of the other choice is
30761
+ * at most one redundant NOT_DETECTED per camera per restart.
30762
+ */
30763
+ onCleared(key, release) {
30764
+ const entry = this.entries.get(key);
30765
+ if (entry !== void 0 && entry.state !== "detected") return;
30766
+ const timer = this.setTimer(() => {
30767
+ this.entries.set(key, {
30768
+ state: "idle",
30769
+ timer: null
30770
+ });
30771
+ release();
30772
+ }, this.holdMs);
30773
+ this.entries.set(key, {
30774
+ state: "holding",
30775
+ timer
30776
+ });
30777
+ }
30778
+ /** What Alexa currently believes for this key — for logs and assertions. */
30779
+ believedDetected(key) {
30780
+ const state = this.entries.get(key)?.state;
30781
+ return state === "detected" || state === "holding";
30782
+ }
30783
+ /** Cancel every pending clear and forget all state. */
30784
+ clear() {
30785
+ for (const entry of this.entries.values()) if (entry.timer !== null) this.clearTimer(entry.timer);
30786
+ this.entries.clear();
30787
+ }
30788
+ };
30789
+ //#endregion
29957
30790
  //#region src/lwa-event-gateway.ts
29958
30791
  /**
29959
30792
  * LWA Event Gateway client for Alexa proactive events.
@@ -29991,6 +30824,32 @@ var LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token";
29991
30824
  var THROTTLE_WINDOW_MS = 3e4;
29992
30825
  var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
29993
30826
  var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
30827
+ /** The interface an endpoint must advertise for a `DoorbellPress` to survive
30828
+ * the Alexa cloud. */
30829
+ var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
30830
+ /**
30831
+ * The interface an endpoint must advertise for an `ObjectDetection` event to
30832
+ * survive the Alexa cloud — and which `buildParentCameraEndpoint` does NOT
30833
+ * currently declare (the two `capabilities.push(SMART_VISION_*)` lines are
30834
+ * commented out there, deliberately).
30835
+ *
30836
+ * Measured 2026-08-07: 223 `smartvision person posted status=202` in 692 s on
30837
+ * endpoint 590 alone, every one of them against an endpoint whose discovered
30838
+ * capability list has never contained this string. The Event Gateway answers
30839
+ * 202 because the ENVELOPE parsed; the cloud then drops the event because the
30840
+ * endpoint cannot host it. This is D76 exactly — an accepted POST is not
30841
+ * evidence of delivery — and it is why the operator sees no events in the app
30842
+ * while the log reads as though they were delivered.
30843
+ */
30844
+ var SMART_VISION_INTERFACE = "Alexa.SmartVision.ObjectDetectionSensor";
30845
+ /**
30846
+ * How often the "SmartVision is not declared" drop is allowed to speak. A
30847
+ * branch that drops work must log it (`CLAUDE.md`), but at 223 drops per 11
30848
+ * minutes a per-event line is a line nobody reads — the same lesson the egress
30849
+ * teardown warn taught on 2026-08-07. First occurrence per endpoint, then every
30850
+ * hundredth, carrying the running count so the volume is never hidden.
30851
+ */
30852
+ var SMART_VISION_DROP_LOG_EVERY = 100;
29994
30853
  var LwaEventGateway = class {
29995
30854
  deps;
29996
30855
  unsubscribe = null;
@@ -29998,10 +30857,17 @@ var LwaEventGateway = class {
29998
30857
  unsubscribeBattery = null;
29999
30858
  refreshTimer = null;
30000
30859
  lastSentAt = /* @__PURE__ */ new Map();
30001
- /** Last reported motion detection state per `userId|endpointId` — only
30002
- * state TRANSITIONS are pushed to Alexa (a chatty source re-emitting
30003
- * `detected:true` every tick must not flood the Event Gateway). */
30004
- lastMotionState = /* @__PURE__ */ new Map();
30860
+ /**
30861
+ * What Alexa believes about each `userId|endpointId`'s `detectionState`,
30862
+ * and the falling-edge hold that keeps a flapping source from becoming a
30863
+ * flapping ChangeReport stream. This is the SINGLE authority on the motion
30864
+ * state we have reported — see `motion-clear-hold.ts` for the measurement
30865
+ * that made a plain transition dedupe insufficient.
30866
+ */
30867
+ motionHold = new MotionClearHold({ holdMs: DEFAULT_MOTION_CLEAR_HOLD_MS });
30868
+ /** Per-endpoint count of `ObjectDetection` events dropped because the
30869
+ * endpoint does not declare {@link SMART_VISION_INTERFACE}. */
30870
+ smartVisionDropped = /* @__PURE__ */ new Map();
30005
30871
  /** Last reported integer battery percentage per `userId|endpointId` —
30006
30872
  * battery providers may push the same level every poll cycle and
30007
30873
  * Alexa Smart Home rate-limits ChangeReports per property; dedupe
@@ -30046,15 +30912,30 @@ var LwaEventGateway = class {
30046
30912
  if (this.refreshTimer) clearInterval(this.refreshTimer);
30047
30913
  this.refreshTimer = null;
30048
30914
  this.lastSentAt.clear();
30049
- this.lastMotionState.clear();
30915
+ this.motionHold.clear();
30916
+ this.smartVisionDropped.clear();
30050
30917
  this.lastBatteryLevel.clear();
30051
30918
  }
30052
30919
  async onDoorbellPress(data) {
30053
30920
  const childDevice = await this.deps.ctx.api.deviceManager.getDevice.query({ deviceId: data.deviceId }).catch(() => null);
30054
- if (!childDevice) return;
30921
+ if (!childDevice) {
30922
+ this.deps.logger.warn("alexa lwa-gateway: doorbell press DROPPED — the pressing device is not in the registry", { tags: { deviceId: data.deviceId } });
30923
+ return;
30924
+ }
30055
30925
  const parentDeviceId = childDevice.parentDeviceId ?? data.deviceId;
30056
- if (!this.deps.listExposed().some((e) => e.deviceId === parentDeviceId)) return;
30926
+ if (!this.deps.listExposed().some((e) => e.deviceId === parentDeviceId)) {
30927
+ this.deps.logger.info("alexa lwa-gateway: doorbell press ignored — the camera is not exposed to Alexa", { tags: { deviceId: parentDeviceId } });
30928
+ return;
30929
+ }
30057
30930
  const endpointId = String(parentDeviceId);
30931
+ const signature = this.deps.getExportedSignature(parentDeviceId);
30932
+ if (signature !== null && !signature.includes(DOORBELL_INTERFACE)) this.deps.logger.warn(`alexa lwa-gateway: the endpoint Alexa knows does NOT declare ${DOORBELL_INTERFACE} — this press will be accepted by the gateway and DROPPED by the cloud; the discovery reconcile must re-push this endpoint`, {
30933
+ tags: { deviceId: parentDeviceId },
30934
+ meta: {
30935
+ endpointId,
30936
+ signature
30937
+ }
30938
+ });
30058
30939
  const linked = this.deps.listLinkedAccounts();
30059
30940
  if (linked.length === 0) {
30060
30941
  this.deps.logger.info("alexa lwa-gateway: doorbell press but no linked accounts — skipped", { meta: { parentDeviceId } });
@@ -30063,7 +30944,10 @@ var LwaEventGateway = class {
30063
30944
  if (!this.hasProactiveCredentials()) return;
30064
30945
  for (const account of linked) {
30065
30946
  if (!this.findToken(account.userId)) {
30066
- this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", { meta: { userId: account.userId } });
30947
+ this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", {
30948
+ tags: { deviceId: parentDeviceId },
30949
+ meta: { userId: account.userId }
30950
+ });
30067
30951
  continue;
30068
30952
  }
30069
30953
  if (this.isThrottled({
@@ -30091,12 +30975,16 @@ var LwaEventGateway = class {
30091
30975
  return false;
30092
30976
  }
30093
30977
  async postDoorbellPress(opts) {
30094
- await this.postEventBody(opts.userId, opts.endpointId, buildDoorbellPressPayload(opts), "doorbell press");
30978
+ await this.postEventBody(opts.userId, opts.endpointId, (accessToken) => buildDoorbellPressPayload({
30979
+ ...opts,
30980
+ accessToken
30981
+ }), "doorbell press");
30095
30982
  }
30096
30983
  /**
30097
- * Camera-native motion flipped. Report each detection-state TRANSITION as
30098
- * an `Alexa.MotionSensor` ChangeReport, deduped per endpoint so a chatty
30099
- * source re-emitting `detected:true` every tick doesn't flood the gateway.
30984
+ * Camera-native motion flipped. Report it as an `Alexa.MotionSensor`
30985
+ * ChangeReport: the rising edge immediately, the falling edge only once the
30986
+ * source has stayed quiet (see `MotionClearHold` for the 590 measurement
30987
+ * that made a plain transition dedupe insufficient).
30100
30988
  */
30101
30989
  async onMotionChanged(data) {
30102
30990
  if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
@@ -30107,24 +30995,90 @@ var LwaEventGateway = class {
30107
30995
  for (const account of linked) {
30108
30996
  if (!this.findToken(account.userId)) continue;
30109
30997
  const key = `${account.userId}|${endpointId}`;
30110
- if (this.lastMotionState.get(key) === data.detected) continue;
30111
- this.lastMotionState.set(key, data.detected);
30112
- await this.postEventBody(account.userId, endpointId, buildMotionChangeReportPayload({
30998
+ if (!data.detected) {
30999
+ this.motionHold.onCleared(key, () => {
31000
+ this.postMotionChange({
31001
+ userId: account.userId,
31002
+ endpointId,
31003
+ detected: false,
31004
+ timestamp: Date.now()
31005
+ }).catch((err) => {
31006
+ this.deps.logger.warn("alexa lwa-gateway: held motion clear failed to post", {
31007
+ tags: { deviceId: data.deviceId },
31008
+ meta: {
31009
+ endpointId,
31010
+ error: err instanceof Error ? err.message : String(err)
31011
+ }
31012
+ });
31013
+ });
31014
+ });
31015
+ continue;
31016
+ }
31017
+ if (!this.motionHold.onDetected(key)) continue;
31018
+ await this.postMotionChange({
30113
31019
  userId: account.userId,
30114
31020
  endpointId,
30115
- detected: data.detected,
31021
+ detected: true,
30116
31022
  timestamp: data.timestamp
30117
- }), `motion ${data.detected ? "DETECTED" : "NOT_DETECTED"}`);
30118
- if (data.detected) await this.postEventBody(account.userId, endpointId, buildSmartVisionObjectDetectionEvent({
31023
+ });
31024
+ await this.postObjectDetection({
30119
31025
  userId: account.userId,
31026
+ deviceId: data.deviceId,
30120
31027
  endpointId,
30121
- detections: [{
30122
- eventIdentifier: `${endpointId}-${data.timestamp}`,
30123
- imageNetClass: "person"
30124
- }],
30125
31028
  timestamp: data.timestamp
30126
- }), `smartvision person`);
31029
+ });
31030
+ }
31031
+ }
31032
+ async postMotionChange(opts) {
31033
+ await this.postEventBody(opts.userId, opts.endpointId, (accessToken) => buildMotionChangeReportPayload({
31034
+ ...opts,
31035
+ accessToken
31036
+ }), `motion ${opts.detected ? "DETECTED" : "NOT_DETECTED"}`);
31037
+ }
31038
+ /**
31039
+ * Pair a reported DETECTED with a SmartVision `ObjectDetection` event — the
31040
+ * event that populates the per-camera Events box in the Alexa app
31041
+ * (`Alexa.MotionSensor` alone only feeds the account-wide Activity feed).
31042
+ *
31043
+ * **Gated on the endpoint actually declaring the interface, and today that
31044
+ * gate is closed.** `buildParentCameraEndpoint` does not push
31045
+ * `SMART_VISION_OBJECT_DETECTION_SENSOR`, so every one of these events was
31046
+ * being accepted by the gateway (202) and discarded by the cloud. Posting
31047
+ * into that is not harmless: it is ~1 POST/s per flapping camera against a
31048
+ * rate-limited gateway, and it makes the log assert a delivery that never
31049
+ * happened. The doorbell path learned the same lesson today and only warns
31050
+ * — it can, because `Alexa.DoorbellEventSource` IS built for a doorbell and
31051
+ * a missing one means stale discovery, i.e. a bet worth taking. Here the
31052
+ * interface is absent by construction, so the bet is a certain loss.
31053
+ *
31054
+ * Re-opening this gate is a design item, not a toggle: see
31055
+ * `docs/design/2026-08-07-alexa-events-and-hksv-assessment.md`.
31056
+ */
31057
+ async postObjectDetection(opts) {
31058
+ const signature = this.deps.getExportedSignature(opts.deviceId);
31059
+ if (signature === null || !signature.includes(SMART_VISION_INTERFACE)) {
31060
+ const dropped = (this.smartVisionDropped.get(opts.endpointId) ?? 0) + 1;
31061
+ this.smartVisionDropped.set(opts.endpointId, dropped);
31062
+ if (dropped === 1 || dropped % SMART_VISION_DROP_LOG_EVERY === 0) this.deps.logger.info(`alexa lwa-gateway: ObjectDetection NOT posted — the endpoint Alexa knows does not declare ${SMART_VISION_INTERFACE}, so the cloud would accept and discard it`, {
31063
+ tags: { deviceId: opts.deviceId },
31064
+ meta: {
31065
+ endpointId: opts.endpointId,
31066
+ droppedSoFar: dropped,
31067
+ signature
31068
+ }
31069
+ });
31070
+ return;
30127
31071
  }
31072
+ await this.postEventBody(opts.userId, opts.endpointId, (accessToken) => buildSmartVisionObjectDetectionEvent({
31073
+ userId: opts.userId,
31074
+ endpointId: opts.endpointId,
31075
+ detections: [{
31076
+ eventIdentifier: `${opts.endpointId}-${opts.timestamp}`,
31077
+ imageNetClass: "person"
31078
+ }],
31079
+ timestamp: opts.timestamp,
31080
+ accessToken
31081
+ }), `smartvision person`);
30128
31082
  }
30129
31083
  /**
30130
31084
  * Battery slice changed. Push an `Alexa.BatteryLevelSensor`
@@ -30146,11 +31100,12 @@ var LwaEventGateway = class {
30146
31100
  const key = `${account.userId}|${endpointId}`;
30147
31101
  if (this.lastBatteryLevel.get(key) === level) continue;
30148
31102
  this.lastBatteryLevel.set(key, level);
30149
- await this.postEventBody(account.userId, endpointId, buildBatteryChangeReportPayload({
31103
+ await this.postEventBody(account.userId, endpointId, (accessToken) => buildBatteryChangeReportPayload({
30150
31104
  userId: account.userId,
30151
31105
  endpointId,
30152
31106
  batteryLevel: level,
30153
- timestamp: Date.now()
31107
+ timestamp: Date.now(),
31108
+ accessToken
30154
31109
  }), `battery ${level}%`);
30155
31110
  }
30156
31111
  }
@@ -30185,7 +31140,7 @@ var LwaEventGateway = class {
30185
31140
  let attemptedAccounts = 0;
30186
31141
  for (const account of linked) {
30187
31142
  if (!this.findToken(account.userId)) continue;
30188
- await this.postEventBody(account.userId, "discovery", buildDeleteReportPayload(account.userId, endpointIds), `discovery delete (manual purge, ${endpointIds.length})`);
31143
+ await this.postEventBody(account.userId, "discovery", (accessToken) => buildDeleteReportPayload(account.userId, endpointIds, accessToken), `discovery delete (manual purge, ${endpointIds.length})`);
30189
31144
  attemptedAccounts += 1;
30190
31145
  }
30191
31146
  return {
@@ -30194,13 +31149,21 @@ var LwaEventGateway = class {
30194
31149
  attemptedAccounts
30195
31150
  };
30196
31151
  }
30197
- /** Public delivery used by the SyncEngine. Returns true on gateway 2xx. */
30198
- async postReport(userId, body, label) {
30199
- return this.postEventBody(userId, "discovery", body, label);
31152
+ /** Public delivery used by the SyncEngine. Returns true on gateway 2xx.
31153
+ * `buildBody` is handed the access token the request will actually be sent
31154
+ * with, so the body's `scope.token` and the `Authorization` header can never
31155
+ * disagree — including after a 401 refresh. */
31156
+ async postReport(userId, buildBody, label) {
31157
+ return this.postEventBody(userId, "discovery", buildBody, label);
30200
31158
  }
30201
31159
  /** POST a proactive-event body to the region's Event Gateway, refreshing
30202
- * the LWA token once on 401. */
30203
- async postEventBody(userId, endpointId, body, label) {
31160
+ * the LWA token once on 401.
31161
+ *
31162
+ * The body is BUILT PER ATTEMPT from the token that attempt uses: the
31163
+ * Smart Home payloads carry the bearer token inside `scope.token` as well
31164
+ * as in the header, and a body built with the expired token then retried
31165
+ * with a fresh header would ship a token Amazon has already rejected. */
31166
+ async postEventBody(userId, endpointId, buildBody, label) {
30204
31167
  const url = EVENT_GATEWAY_URLS[this.deps.getRegion()];
30205
31168
  const send = async (accessToken) => fetch(url, {
30206
31169
  method: "POST",
@@ -30208,7 +31171,7 @@ var LwaEventGateway = class {
30208
31171
  "Content-Type": "application/json",
30209
31172
  Authorization: `Bearer ${accessToken}`
30210
31173
  },
30211
- body: JSON.stringify(body)
31174
+ body: JSON.stringify(buildBody(accessToken))
30212
31175
  });
30213
31176
  const initial = this.findToken(userId);
30214
31177
  if (!initial) return false;
@@ -30248,7 +31211,8 @@ var LwaEventGateway = class {
30248
31211
  }
30249
31212
  this.deps.logger.info(`alexa lwa-gateway: ${label} posted`, { meta: {
30250
31213
  userId,
30251
- endpointId
31214
+ endpointId,
31215
+ status: response.status
30252
31216
  } });
30253
31217
  return true;
30254
31218
  }
@@ -30336,7 +31300,7 @@ function buildDoorbellPressPayload(opts) {
30336
31300
  endpoint: {
30337
31301
  scope: {
30338
31302
  type: "BearerToken",
30339
- token: "replaced-by-authorization-header"
31303
+ token: opts.accessToken
30340
31304
  },
30341
31305
  endpointId: opts.endpointId
30342
31306
  },
@@ -30359,7 +31323,7 @@ function buildMotionChangeReportPayload(opts) {
30359
31323
  endpoint: {
30360
31324
  scope: {
30361
31325
  type: "BearerToken",
30362
- token: "replaced-by-authorization-header"
31326
+ token: opts.accessToken
30363
31327
  },
30364
31328
  endpointId: opts.endpointId
30365
31329
  },
@@ -30390,7 +31354,7 @@ function buildBatteryChangeReportPayload(opts) {
30390
31354
  endpoint: {
30391
31355
  scope: {
30392
31356
  type: "BearerToken",
30393
- token: "replaced-by-authorization-header"
31357
+ token: opts.accessToken
30394
31358
  },
30395
31359
  endpointId: opts.endpointId
30396
31360
  },
@@ -30420,7 +31384,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
30420
31384
  endpoint: {
30421
31385
  scope: {
30422
31386
  type: "BearerToken",
30423
- token: "replaced-by-authorization-header"
31387
+ token: opts.accessToken
30424
31388
  },
30425
31389
  endpointId: opts.endpointId
30426
31390
  },
@@ -30435,7 +31399,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
30435
31399
  })) }
30436
31400
  } };
30437
31401
  }
30438
- function buildAddOrUpdateReportPayload(userId, endpoints) {
31402
+ function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
30439
31403
  return { event: {
30440
31404
  header: {
30441
31405
  namespace: "Alexa.Discovery",
@@ -30447,12 +31411,12 @@ function buildAddOrUpdateReportPayload(userId, endpoints) {
30447
31411
  endpoints,
30448
31412
  scope: {
30449
31413
  type: "BearerToken",
30450
- token: "replaced-by-authorization-header"
31414
+ token: accessToken
30451
31415
  }
30452
31416
  }
30453
31417
  } };
30454
31418
  }
30455
- function buildDeleteReportPayload(userId, endpointIds) {
31419
+ function buildDeleteReportPayload(userId, endpointIds, accessToken) {
30456
31420
  return { event: {
30457
31421
  header: {
30458
31422
  namespace: "Alexa.Discovery",
@@ -30464,12 +31428,34 @@ function buildDeleteReportPayload(userId, endpointIds) {
30464
31428
  endpoints: endpointIds.map((endpointId) => ({ endpointId })),
30465
31429
  scope: {
30466
31430
  type: "BearerToken",
30467
- token: "replaced-by-authorization-header"
31431
+ token: accessToken
30468
31432
  }
30469
31433
  }
30470
31434
  } };
30471
31435
  }
30472
31436
  //#endregion
31437
+ //#region src/endpoint-signature.ts
31438
+ /** One capability's identity as Alexa distinguishes it. */
31439
+ function capabilityKey(capability) {
31440
+ const instance = typeof capability.instance === "string" ? capability.instance : "";
31441
+ return instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
31442
+ }
31443
+ /** Stable, order-independent signature of ONE endpoint's advertised shape. */
31444
+ function endpointKey(endpoint) {
31445
+ const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
31446
+ const categories = [...endpoint.displayCategories].sort().join(",");
31447
+ return `${endpoint.endpointId}|${categories}|${interfaces}`;
31448
+ }
31449
+ /**
31450
+ * Stable signature of the endpoint set built for ONE device. Two builds that
31451
+ * would land identically in Alexa's registry produce the same string; any
31452
+ * added, removed or renamed interface — or a changed display category —
31453
+ * produces a different one.
31454
+ */
31455
+ function endpointsSignature(endpoints) {
31456
+ return endpoints.map(endpointKey).sort().join(";");
31457
+ }
31458
+ //#endregion
30473
31459
  //#region src/sync-engine.ts
30474
31460
  var SyncEngine = class {
30475
31461
  deps;
@@ -30486,7 +31472,7 @@ var SyncEngine = class {
30486
31472
  };
30487
31473
  const endpoints = await this.deps.buildEndpoints(deviceId);
30488
31474
  if (endpoints.length === 0) {
30489
- await this.recordEndpoints(deviceId, []);
31475
+ await this.recordEndpoints(deviceId, [], null);
30490
31476
  return {
30491
31477
  deviceCount: 0,
30492
31478
  endpointCount: 0,
@@ -30497,7 +31483,7 @@ var SyncEngine = class {
30497
31483
  const endpointIds = endpoints.map((e) => e.endpointId);
30498
31484
  const failures = [];
30499
31485
  for (const account of accounts) if (!await this.deps.postAddOrUpdate(account.userId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
30500
- await this.recordEndpoints(deviceId, endpointIds);
31486
+ await this.recordEndpoints(deviceId, endpointIds, endpointsSignature(endpoints));
30501
31487
  return {
30502
31488
  deviceCount: 1,
30503
31489
  endpointCount: endpoints.length,
@@ -30543,7 +31529,7 @@ var SyncEngine = class {
30543
31529
  const endpoints = await this.deps.buildEndpoints(deviceId);
30544
31530
  if (endpoints.length === 0) continue;
30545
31531
  if (!await this.deps.postAddOrUpdate(userId, endpoints)) failures.push(`${userId}:${deviceId}`);
30546
- await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId));
31532
+ await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId), endpointsSignature(endpoints));
30547
31533
  deviceCount += 1;
30548
31534
  endpointCount += endpoints.length;
30549
31535
  }
@@ -30565,7 +31551,7 @@ var SyncEngine = class {
30565
31551
  const accounts = this.deps.listLinkedAccounts();
30566
31552
  const failures = [];
30567
31553
  for (const account of accounts) if (!await this.deps.postDelete(account.userId, ids)) failures.push(`${account.userId}:${deviceId}`);
30568
- await this.recordEndpoints(deviceId, []);
31554
+ await this.recordEndpoints(deviceId, [], null);
30569
31555
  return {
30570
31556
  deviceId,
30571
31557
  endpointCount: ids.length,
@@ -30573,11 +31559,76 @@ var SyncEngine = class {
30573
31559
  failures
30574
31560
  };
30575
31561
  }
30576
- async recordEndpoints(deviceId, ids) {
31562
+ /**
31563
+ * Re-push ONE exposed device if the endpoint shape it builds today differs
31564
+ * from the shape Alexa was last told about.
31565
+ *
31566
+ * A build that yields NO endpoints (transient registry miss) is never
31567
+ * pushed — an empty AddOrUpdateReport is an Alexa protocol error — and never
31568
+ * clears the recorded signature: forgetting it would make the next reconcile
31569
+ * re-push a shape Alexa already has.
31570
+ */
31571
+ async reconcileDevice(deviceId) {
31572
+ if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return {
31573
+ deviceId,
31574
+ pushed: false
31575
+ };
31576
+ if (this.deps.listLinkedAccounts().length === 0 || !this.deps.hasProactiveCredentials()) return {
31577
+ deviceId,
31578
+ pushed: false
31579
+ };
31580
+ const endpoints = await this.deps.buildEndpoints(deviceId);
31581
+ if (endpoints.length === 0) {
31582
+ this.deps.logger.warn("alexa sync: reconcile built ZERO endpoints — leaving Alexa untouched (transient registry miss)", { tags: { deviceId } });
31583
+ return {
31584
+ deviceId,
31585
+ pushed: false
31586
+ };
31587
+ }
31588
+ const current = endpointsSignature(endpoints);
31589
+ const last = this.deps.getExportedSignatures()[deviceId];
31590
+ if (last === current) return {
31591
+ deviceId,
31592
+ pushed: false
31593
+ };
31594
+ this.deps.logger.info("alexa sync: endpoint shape drifted — re-pushing AddOrUpdateReport so the cloud advertises the current interfaces", {
31595
+ tags: { deviceId },
31596
+ meta: {
31597
+ from: last ?? "never-recorded",
31598
+ to: current,
31599
+ interfaces: endpoints.flatMap((e) => e.capabilities.map((c) => c.interface))
31600
+ }
31601
+ });
31602
+ await this.pushDevice(deviceId);
31603
+ return {
31604
+ deviceId,
31605
+ pushed: true
31606
+ };
31607
+ }
31608
+ /** Reconcile every exposed device (boot pass + manual safety net). */
31609
+ async reconcileAllExposed() {
31610
+ const checkedDeviceIds = [];
31611
+ const pushedDeviceIds = [];
31612
+ for (const { deviceId } of this.deps.listExposed()) {
31613
+ checkedDeviceIds.push(deviceId);
31614
+ if ((await this.reconcileDevice(deviceId)).pushed) pushedDeviceIds.push(deviceId);
31615
+ }
31616
+ return {
31617
+ checkedDeviceIds,
31618
+ pushedDeviceIds
31619
+ };
31620
+ }
31621
+ /** Persist the endpoint ids AND the shape signature for a device.
31622
+ * `signature === null` forgets both (delete / empty build). */
31623
+ async recordEndpoints(deviceId, ids, signature) {
30577
31624
  const next = { ...this.deps.getExportedEndpoints() };
30578
31625
  if (ids.length > 0) next[deviceId] = ids;
30579
31626
  else delete next[deviceId];
30580
31627
  await this.deps.setExportedEndpoints(next);
31628
+ const nextSignatures = { ...this.deps.getExportedSignatures() };
31629
+ if (signature !== null) nextSignatures[deviceId] = signature;
31630
+ else delete nextSignatures[deviceId];
31631
+ await this.deps.setExportedSignatures(nextSignatures);
30581
31632
  }
30582
31633
  };
30583
31634
  //#endregion
@@ -30644,6 +31695,14 @@ var DEFAULT_DEVICE_SETTINGS = {
30644
31695
  * tokens to `/oauth/lwa-tokens`, which this addon records.
30645
31696
  * - Admin-UI wizard that walks the operator through skill creation.
30646
31697
  */
31698
+ /** Per-device debounce for the bindings-change → discovery reconcile. A
31699
+ * provider respawn emits one `DeviceBindingsChanged` per cap; reconciling in
31700
+ * the middle of that burst would read a half-registered binding set. */
31701
+ var BINDINGS_RECONCILE_DEBOUNCE_MS = 5e3;
31702
+ /** Delay before the boot-time reconcile pass. Long enough for device providers
31703
+ * to have registered their caps — a device whose bindings have not landed
31704
+ * builds a shape we must never push to Alexa. */
31705
+ var BOOT_RECONCILE_DELAY_MS = 6e4;
30647
31706
  var STREAM_PREFERENCE_OPTIONS = [
30648
31707
  {
30649
31708
  value: "auto",
@@ -30672,6 +31731,7 @@ var DEFAULT_CONFIG = {
30672
31731
  linkedAccounts: [],
30673
31732
  tokens: [],
30674
31733
  exportedEndpoints: {},
31734
+ exportedSignatures: {},
30675
31735
  publicHubUrl: "",
30676
31736
  availableEndpoints: [],
30677
31737
  ingressToken: "",
@@ -30682,6 +31742,8 @@ var SUPPORTED_KINDS = ["camera", "doorbell"];
30682
31742
  var ExportAlexaAddon = class extends BaseAddon {
30683
31743
  lwaGateway = null;
30684
31744
  syncEngine = null;
31745
+ /** Per-device debounce timers for the bindings-change discovery reconcile. */
31746
+ pendingReconcileTimers = /* @__PURE__ */ new Map();
30685
31747
  constructor() {
30686
31748
  super({ ...DEFAULT_CONFIG });
30687
31749
  }
@@ -30779,7 +31841,8 @@ var ExportAlexaAddon = class extends BaseAddon {
30779
31841
  upsertToken: (input) => this.upsertToken(input),
30780
31842
  removeLinkedAccount: (userId) => this.removeLinkedAccount(userId),
30781
31843
  getLwaCredentials: () => this.lwaCredentials(),
30782
- getRegion: () => this.config.region
31844
+ getRegion: () => this.config.region,
31845
+ getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
30783
31846
  });
30784
31847
  this.lwaGateway.start();
30785
31848
  this.syncEngine = new SyncEngine({
@@ -30788,11 +31851,15 @@ var ExportAlexaAddon = class extends BaseAddon {
30788
31851
  listLinkedAccounts: () => this.config.linkedAccounts,
30789
31852
  hasProactiveCredentials: () => this.lwaCredentials() !== null,
30790
31853
  buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
30791
- postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, buildAddOrUpdateReportPayload(userId, endpoints), `add/update (${endpoints.length})`),
30792
- postDelete: (userId, ids) => this.requireGateway().postReport(userId, buildDeleteReportPayload(userId, ids), `delete (${ids.length})`),
31854
+ postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
31855
+ postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
30793
31856
  getExportedEndpoints: () => this.config.exportedEndpoints,
30794
- setExportedEndpoints: (next) => this.updateGlobalSettings({ exportedEndpoints: next })
31857
+ setExportedEndpoints: (next) => this.updateGlobalSettings({ exportedEndpoints: next }),
31858
+ getExportedSignatures: () => this.config.exportedSignatures,
31859
+ setExportedSignatures: (next) => this.updateGlobalSettings({ exportedSignatures: next })
30795
31860
  });
31861
+ this.subscribeBindingsChangedForReconcile();
31862
+ this.scheduleBootDiscoveryReconcile();
30796
31863
  const offRemoved = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
30797
31864
  const id = event.data?.deviceId;
30798
31865
  if (typeof id !== "number") return;
@@ -30908,6 +31975,66 @@ var ExportAlexaAddon = class extends BaseAddon {
30908
31975
  }
30909
31976
  /** Manual "Re-sync devices" button — awaited so the form gets the counts.
30910
31977
  * Pushes EVERY exposed device to every linked account unconditionally. */
31978
+ /**
31979
+ * Reconcile ONE device's Alexa endpoint shape whenever its capability
31980
+ * bindings change. Debounced per device so the burst a provider respawn
31981
+ * produces (one event per cap) collapses into a single rebuild, and so the
31982
+ * reconcile reads the bindings AFTER the burst settles rather than in the
31983
+ * middle of it.
31984
+ */
31985
+ subscribeBindingsChangedForReconcile() {
31986
+ const off = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (event) => {
31987
+ const deviceId = event.data?.deviceId;
31988
+ if (typeof deviceId !== "number") return;
31989
+ if (!this.config.exposed.some((e) => e.deviceId === deviceId)) return;
31990
+ const pending = this.pendingReconcileTimers.get(deviceId);
31991
+ if (pending) clearTimeout(pending);
31992
+ const timer = setTimeout(() => {
31993
+ this.pendingReconcileTimers.delete(deviceId);
31994
+ this.reconcileDeviceDiscovery(deviceId);
31995
+ }, BINDINGS_RECONCILE_DEBOUNCE_MS);
31996
+ if (typeof timer.unref === "function") timer.unref();
31997
+ this.pendingReconcileTimers.set(deviceId, timer);
31998
+ });
31999
+ this.ctx.addDisposer(async () => {
32000
+ off();
32001
+ for (const timer of this.pendingReconcileTimers.values()) clearTimeout(timer);
32002
+ this.pendingReconcileTimers.clear();
32003
+ });
32004
+ }
32005
+ /**
32006
+ * One reconcile pass over every exposed device shortly after boot. This is
32007
+ * what heals a shape that drifted while the addon was DOWN (or under an
32008
+ * older build that had no signature at all) with no operator click — the
32009
+ * doorbell wrapper bound on 615 is exactly that case. Deliberately late
32010
+ * enough that device providers have registered their caps: a device whose
32011
+ * bindings have not landed yet builds a shape we must not push.
32012
+ */
32013
+ scheduleBootDiscoveryReconcile() {
32014
+ const timer = setTimeout(() => {
32015
+ this.requireSyncEngine().reconcileAllExposed().then((r) => {
32016
+ this.ctx.logger.info("export-alexa: boot discovery reconcile", { meta: {
32017
+ checked: r.checkedDeviceIds.length,
32018
+ pushed: r.pushedDeviceIds
32019
+ } });
32020
+ }).catch((err) => {
32021
+ this.ctx.logger.warn("export-alexa: boot discovery reconcile failed", { meta: { error: errMsg(err) } });
32022
+ });
32023
+ }, BOOT_RECONCILE_DELAY_MS);
32024
+ if (typeof timer.unref === "function") timer.unref();
32025
+ this.ctx.addDisposer(async () => clearTimeout(timer));
32026
+ }
32027
+ /** Run (and report) one device's discovery reconcile. Never throws. */
32028
+ async reconcileDeviceDiscovery(deviceId) {
32029
+ try {
32030
+ if (!(await this.requireSyncEngine().reconcileDevice(deviceId)).pushed) this.ctx.logger.debug("export-alexa: bindings changed but the Alexa endpoint shape is unchanged — no re-push", { tags: { deviceId } });
32031
+ } catch (err) {
32032
+ this.ctx.logger.warn("export-alexa: bindings-change discovery reconcile failed", {
32033
+ tags: { deviceId },
32034
+ meta: { error: errMsg(err) }
32035
+ });
32036
+ }
32037
+ }
30911
32038
  async resyncDiscovery() {
30912
32039
  const r = await this.requireSyncEngine().pushAllExposed();
30913
32040
  this.ctx.logger.info("export-alexa: manual discovery re-sync", { meta: { ...r } });
@@ -31461,7 +32588,9 @@ var ExportAlexaAddon = class extends BaseAddon {
31461
32588
  }
31462
32589
  };
31463
32590
  //#endregion
31464
- exports.AlexaRtcEgress = AlexaRtcEgress;
32591
+ exports.ALEXA_EGRESS_PROFILE = ALEXA_EGRESS_PROFILE;
31465
32592
  exports.ExportAlexaAddon = ExportAlexaAddon;
31466
32593
  exports.default = ExportAlexaAddon;
32594
+ exports.acquireAlexaEgress = acquireAlexaEgress;
31467
32595
  exports.exportAlexaActions = exportAlexaActions;
32596
+ exports.releaseAlexaEgress = releaseAlexaEgress;