@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.
@@ -1,603 +1,4 @@
1
1
  import * as crypto$1 from "node:crypto";
2
- import { createHash } from "node:crypto";
3
- //#region ../types/dist/event-category-41fKf-q9.mjs
4
- var EventCategory = /* @__PURE__ */ function(EventCategory) {
5
- EventCategory["SystemBoot"] = "system.boot";
6
- EventCategory["SystemAddonsReady"] = "system.addons-ready";
7
- EventCategory["SystemRestarting"] = "system.restarting";
8
- /**
9
- * Fired exactly once after the hub finishes booting following a
10
- * restart that was triggered by `RestartCoordinator` (today: framework
11
- * live-update). Payload is the marker that was on disk at boot
12
- * (`PendingRestartMarkerPayload`); admin UI listens for it to display a
13
- * success toast describing what changed.
14
- *
15
- * Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
16
- */
17
- EventCategory["SystemRestartCompleted"] = "system.restart-completed";
18
- /**
19
- * Readiness transition for a capability provider. Every producer emits
20
- * this event on `onInitialize` completion, `onDestroy`, and
21
- * `$node.reconnect`; every consumer that needs to gate on a cross-process
22
- * cap subscribes via the kernel's readiness module (`awaitReady` /
23
- * `onReadyState`) instead of polling. Payload is
24
- * `SystemReadyStatePayload` — see event-bus.ts.
25
- */
26
- EventCategory["SystemReadyState"] = "system.ready-state";
27
- EventCategory["AddonStarted"] = "addon.started";
28
- EventCategory["AddonStopped"] = "addon.stopped";
29
- EventCategory["AddonRestarted"] = "addon.restarted";
30
- EventCategory["AddonUpdated"] = "addon.updated";
31
- EventCategory["AddonInstalled"] = "addon.installed";
32
- EventCategory["AddonUninstalled"] = "addon.uninstalled";
33
- EventCategory["AddonCrashed"] = "addon.crashed";
34
- EventCategory["AddonError"] = "addon.error";
35
- EventCategory["AddonPageReady"] = "addon.page-ready";
36
- EventCategory["AddonWidgetReady"] = "addon.widget-ready";
37
- /**
38
- * Addon failed to load (import or initialize). Emitted by the kernel's
39
- * AddonHealthMonitor only AFTER the boot grace period ends — failures
40
- * during the first 5 minutes are silently retried without alerting
41
- * (slow-starting addons must have time to come up). Post-grace, this
42
- * event is emitted exactly once per failure-streak; AlertCenter
43
- * consumes it to create a persistent operator-visible alert.
44
- *
45
- * Payload: `{ packageName, addonId?, error: { message, stack }, retryCount, nextRetryAt }`.
46
- */
47
- EventCategory["AddonLoadFailed"] = "addon.load-failed";
48
- /**
49
- * Addon recovered from a previous failure. Emitted when an addon
50
- * transitions from `failed` back to `healthy` (typically via the
51
- * monitor's auto-retry loop, or after manual `addons.retryLoad`).
52
- * AlertCenter dismisses the corresponding `AddonLoadFailed` alert
53
- * on this event.
54
- */
55
- EventCategory["AddonLoadRecovered"] = "addon.load-recovered";
56
- /**
57
- * Monitor scheduled the next retry for a failed addon. Transient —
58
- * surfaced to the UI for live-updating the "next retry in Ns"
59
- * countdown on the Addons page row, NOT persisted as an alert.
60
- */
61
- EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
62
- /**
63
- * A liveness invariant of this node is false — it has no devices, every
64
- * camera is offline, or a camera that was recording has stopped producing
65
- * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
66
- * `findingId` is stable across ticks), only after its boot grace period.
67
- * AlertCenter raises a persistent operator-visible alert.
68
- *
69
- * It exists because on 2026-08-03 the hub ran four hours with zero devices
70
- * and zero recordings while every surface stayed quiet.
71
- *
72
- * Payload: `{ findingId, severity, title, message, deviceId? }`.
73
- */
74
- EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
75
- /**
76
- * A previously reported liveness fault is true again. AlertCenter dismisses
77
- * the matching `SystemLivenessFailed` alert.
78
- *
79
- * Payload: `{ findingId }`.
80
- */
81
- EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
82
- /**
83
- * Monitor is attempting to reload a failed addon NOW. UI uses this
84
- * to show a spinner during the retry attempt. Same transient nature
85
- * as AddonRetryScheduled.
86
- */
87
- EventCategory["AddonRetryAttempting"] = "addon.retry-attempting";
88
- EventCategory["DeviceRegistered"] = "device.registered";
89
- EventCategory["DeviceUnregistered"] = "device.unregistered";
90
- EventCategory["DeviceEnabled"] = "device.enabled";
91
- EventCategory["DeviceDisabled"] = "device.disabled";
92
- EventCategory["DeviceSettingsUpdated"] = "device.settings-updated";
93
- /**
94
- * Emitted when the set of native capability providers bound to a device
95
- * changes — e.g. an addon registers a new native cap via
96
- * `DeviceContext.registerNativeCap`, or all native bindings for a device
97
- * are cleared on removal. Hub consumers re-resolve device-proxy routes
98
- * when this fires.
99
- */
100
- EventCategory["DeviceBindingsChanged"] = "device.bindings-changed";
101
- /**
102
- * Emitted when the operator-organisational meta surface changes
103
- * (`name` / `location` / `disabled`). Payload: `{deviceId, field,
104
- * value}`. Live consumers (UI device list, alert center) react
105
- * without polling. Distinct from `DeviceSettingsUpdated` which
106
- * fires on hardware-config changes (host/port/credentials/etc).
107
- */
108
- EventCategory["DeviceMetaChanged"] = "device.meta-changed";
109
- /**
110
- * Emitted by `BaseDevice.updateSourceInfo()` after a successful patch
111
- * to the device's upstream-system identity / rendering envelope. The
112
- * full new SourceInfo travels in the payload so cross-process listeners
113
- * (UI, export adapters) don't need to re-read the meta blob.
114
- *
115
- * Payload: `{deviceId, sourceInfo}`.
116
- *
117
- * Distinct from `DeviceMetaChanged` which covers the operator-edited
118
- * surface (`name` / `location` / `disabled`). The two share the
119
- * persistence layer (both ride on `device-manager.setMetadata` for
120
- * SourceInfo, or `setName`/`setLocation`/`setDisabled` for the meta
121
- * fields) but consumers care about different subsets.
122
- */
123
- EventCategory["DeviceSourceInfoChanged"] = "device.source-info-changed";
124
- /**
125
- * Emitted by DeviceStreamWiringService after a successful
126
- * `stream-broker.registerDeviceStreams` call. Payload includes
127
- * deviceId — consumers look up the full registered device info via
128
- * `brokerManager.getRegisteredDevice(deviceId)`.
129
- *
130
- * Replaces the legacy `StreamRouterService.onDeviceRegistered` callback.
131
- */
132
- EventCategory["DeviceStreamsRegistered"] = "device.streams-registered";
133
- /**
134
- * Device-level "fully provisioned" signal — the EXPORT trigger. Emitted once
135
- * a device's persisted export-relevant shape (its `DeviceFeature` set) is
136
- * established or changes, carrying `{deviceId, fingerprint, generation}`.
137
- * Export adapters (Alexa / HAP) react to THIS instead of the chatty per-cap
138
- * `DeviceBindingsChanged`, turning a boot's incomplete→complete trickle into
139
- * one clean delta. `fingerprint` is `canonicalDeviceFingerprint(shape)`.
140
- *
141
- * Spec: docs/superpowers/specs/2026-06-01-alexa-hap-export-reconciler-design.md
142
- */
143
- EventCategory["DeviceProvisioned"] = "device.provisioned";
144
- /**
145
- * Fires once when a device's initial feature-probe completes successfully
146
- * (lastProbedAt 0→>0); exported shape is now stable. Telemetry (may be
147
- * dropped) — consumers MUST also gate on the device record's `probed` flag.
148
- */
149
- EventCategory["DeviceReady"] = "device.ready";
150
- EventCategory["IntegrationEnabled"] = "integration.enabled";
151
- EventCategory["IntegrationDisabled"] = "integration.disabled";
152
- EventCategory["IntegrationDeleted"] = "integration.deleted";
153
- /** Emitted when a broker connection's status flips (connected /
154
- * disconnected / error). Carries `{ brokerId, status, error? }`. */
155
- EventCategory["BrokerStatusChanged"] = "broker.status-changed";
156
- /** Emitted for every subscription-matched message routed by a
157
- * broker provider. Carries `{ brokerId, subscriptionId, key, payload }`.
158
- * Consumers filter by `brokerId` + `subscriptionId` in the handler. */
159
- EventCategory["BrokerMessage"] = "broker.message";
160
- EventCategory["ProviderStarted"] = "provider.started";
161
- EventCategory["ProviderStopped"] = "provider.stopped";
162
- EventCategory["ProcessCrashed"] = "process.crashed";
163
- EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
164
- EventCategory["ProcessRestarted"] = "process.restarted";
165
- EventCategory["RecordingStarted"] = "recording.started";
166
- EventCategory["RecordingStopped"] = "recording.stopped";
167
- EventCategory["RecordingError"] = "recording.error";
168
- EventCategory["RecordingHealthDegraded"] = "recording.health.degraded";
169
- EventCategory["RecordingStorageCritical"] = "recording.storage.critical";
170
- EventCategory["RecordingSegmentWritten"] = "recording.segment.written";
171
- EventCategory["RecordingPolicyFallback"] = "recording.policy.fallback";
172
- EventCategory["RecordingRetentionCompleted"] = "recording.retention.completed";
173
- /** Export render progress (0–100). Telemetry (D8): a lost tick is a stale
174
- * progress bar the client reconciles via `recordingExport.getExport`. */
175
- EventCategory["RecordingExportProgress"] = "recording.export.progress";
176
- EventCategory["RecordingExportCompleted"] = "recording.export.completed";
177
- EventCategory["RecordingExportFailed"] = "recording.export.failed";
178
- EventCategory["DetectionEvent"] = "detection.event";
179
- EventCategory["SessionTrackNew"] = "session.track.new";
180
- EventCategory["SessionTrackExpired"] = "session.track.expired";
181
- EventCategory["BenchmarkProgress"] = "benchmark.progress";
182
- EventCategory["PlatformProbePhase"] = "platform-probe.phase";
183
- EventCategory["PipelineProgress"] = "pipeline.progress";
184
- /** Per-frame execution trace emitted by the pipeline executor for live observability. */
185
- EventCategory["PipelineTrace"] = "pipeline.trace";
186
- /**
187
- * Raw inference output emitted by `addon-pipeline-runner` after running the
188
- * detection pipeline on a frame. Carries the `FrameResult` (with
189
- * `detections[]`, `width`/`height`, timing debug) — never the frame
190
- * buffer itself. Hub-side consumers (analysis pipeline, class filters,
191
- * notifications) subscribe to this and re-emit `detection.result` after
192
- * post-processing.
193
- */
194
- EventCategory["PipelineInferenceResult"] = "pipeline.inference-result";
195
- /**
196
- * Camera lifecycle event emitted by `addon-pipeline-orchestrator` when it
197
- * assigns or unassigns a camera to/from an agent. Carries no frame data;
198
- * pure observability for UI dashboards and metrics consumers.
199
- */
200
- EventCategory["PipelineCameraAssigned"] = "pipeline.camera-assigned";
201
- EventCategory["PipelineCameraUnassigned"] = "pipeline.camera-unassigned";
202
- /**
203
- * Per-camera pipeline config was mutated by the orchestrator
204
- * (3-level settings change via `setAgentAddonDefaults` /
205
- * `setCameraStepToggle` / `setCameraPipelineForAgent` or a
206
- * pipeline-scoped `applyDeviceSettingsPatch`). Orchestrator
207
- * subscribes to its own emission to hot-reload the assigned runner
208
- * via `attachCamera` so the next frame executes against the new
209
- * engine/steps without waiting for a rebalance or the next
210
- * `DeviceStreamsRegistered` cycle.
211
- */
212
- EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
213
- /**
214
- * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
215
- * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
216
- * capabilities — at boot, on agent online/offline, and on an ingest-node
217
- * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
218
- * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
219
- * cross-process `getIngestOwner` query (push the authority's decision instead
220
- * of polling it on the hot path). Idempotent state — re-emitted on every
221
- * topology change, so a dropped event self-heals on the next one (plus the
222
- * broker's long backstop reconcile query).
223
- */
224
- EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
225
- /**
226
- * Periodic snapshot of per-node pipeline-runner load
227
- * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
228
- * subscribe instead of polling `pipelineRunner.getLocalLoad`.
229
- * `nodeId` carried in the payload + on `event.source.nodeId`.
230
- */
231
- EventCategory["PipelineRunnerLoadSnapshot"] = "pipeline.runner-load-snapshot";
232
- /**
233
- * Periodic snapshot of per-camera pipeline metrics (`CameraMetrics`
234
- * + `deviceId` + `nodeId`). Emitted ~1Hz by every runner for each
235
- * attached camera. UI subscribes to drive overlay phase / fps /
236
- * inference time without polling `getCameraMetrics`.
237
- */
238
- EventCategory["PipelineCameraMetricsSnapshot"] = "pipeline.camera-metrics-snapshot";
239
- /**
240
- * Periodic snapshot of stream-broker per-broker statistics (input
241
- * fps, decoded fps, bitrate, codec). Emitted ~1Hz by every
242
- * stream-broker process for each active broker so the UI can drive
243
- * the Stream / Cluster dashboards without polling
244
- * `streamBroker.listAllProfileSlots` and friends.
245
- */
246
- EventCategory["StreamBrokerMetricsSnapshot"] = "stream-broker.metrics-snapshot";
247
- /**
248
- * Cap event fired by `stream-broker` when a profile slot enters
249
- * "demanded" state — a cam stream has been assigned and at least one
250
- * consumer (RTSP restream, decoded subscriber, WebRTC session, …) is
251
- * present. Camera-provider addons (Reolink Baichuan push, …)
252
- * subscribe to this category to start their underlying transport
253
- * lazily. Payload: `{ deviceId, camStreamId, profile }`.
254
- */
255
- EventCategory["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
256
- /**
257
- * Cap event fired by `stream-broker` when the last consumer leaves a
258
- * previously-demanded cam stream. Providers tear down their
259
- * underlying transport on receipt. Payload: `{ deviceId, camStreamId }`.
260
- */
261
- EventCategory["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
262
- /**
263
- * Cap event fired by `stream-broker` when a broker fails to dial a
264
- * managed-loopback source (today: `pull-rfc4571`) and the publisher
265
- * needs to refresh the cached URL. The lib's TCP server idle-tears-down
266
- * on its own schedule, so a re-publish with a fresh `host:port` is the
267
- * only way to keep the broker dialable. Camera providers (Reolink
268
- * Baichuan native, …) subscribe and respond by re-running their publish
269
- * pipeline.
270
- * Payload: `{ deviceId, camStreamId, brokerId }`.
271
- */
272
- EventCategory["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
273
- /**
274
- * A camera provider changed a device's stream parameters (codec /
275
- * resolution / bitrate / a stream added or removed). A LOW-LATENCY NUDGE
276
- * for the stream-broker's catalog reconcile: it carries NO authoritative
277
- * data — the broker re-PULLS that device's `stream-catalog` cap on receipt
278
- * (the 30s reconcile poll is the backstop if this nudge is dropped). Emitted
279
- * by providers from `stream-params.setProfile`. Payload: `{ deviceId }`.
280
- */
281
- EventCategory["StreamParamsChanged"] = "stream-params.changed";
282
- /**
283
- * Generic per-device runtime-state change. Fired by `device-manager`
284
- * whenever a persisted slice in any cap's `runtimeState` shape
285
- * mutates. Payload: `{deviceId, capName, slice}`. Subscribers are
286
- * the `deviceState` cap router (cross-process listeners) and the
287
- * deviceProxy reactive bindings (`device.state.<capName>.value`).
288
- * Cap-specific events (`battery.onStatusChanged`, …) still fire
289
- * — they're authoritative for callers that want a typed payload
290
- * without filtering on `capName`.
291
- */
292
- EventCategory["DeviceStateChanged"] = "device.state-changed";
293
- /**
294
- * Frame occupancy for a camera CHANGED — a tracked object was gained or
295
- * lost. Carries `{ deviceId, totalObjects, byClass, zones }`.
296
- *
297
- * Emitted only on a change, so a steady scene is silent. It exists so a
298
- * client can stop polling `zoneAnalytics.getCurrentSnapshot`: that was the
299
- * one live badge with no push signal at all, and it cost a request every
300
- * four seconds per visible camera.
301
- *
302
- * Like every event it is telemetry and may be dropped ([D8]) — a consumer
303
- * keeps a slow reconcile rather than trusting it alone.
304
- */
305
- EventCategory["ZoneAnalyticsOccupancyChanged"] = "zone-analytics.occupancy-changed";
306
- /**
307
- * Cap event fired by every device that registers the `battery`
308
- * capability. Mirrors the cap definition's `onStatusChanged`. Carries
309
- * `{ deviceId, status: BatteryStatus }`. Subscribers (alert center,
310
- * snapshot wrapper, UI) react to charge/sleep transitions without
311
- * polling `batteryCapability.getStatus`.
312
- */
313
- EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
314
- /**
315
- * Emitted by the battery cap provider WHEN `wakeForStream` enters the
316
- * "wake in progress" window — between the Baichuan wake-up issue and
317
- * the camera's first dialed-back RTP packet. The stream-broker
318
- * manager subscribes to flip the per-broker placeholder reason to
319
- * `'waking'` so viewers see a labelled "WAKING UP" tile instead of
320
- * the generic `'reconnecting'` frame. Carries `{ deviceId }`. The
321
- * complementary "wake complete" signal is the existing
322
- * `BatteryOnStatusChanged { sleeping: false }` event.
323
- */
324
- EventCategory["BatteryOnWakeStarted"] = "battery.onWakeStarted";
325
- /**
326
- * Cap event fired by every device that registers the `doorbell`
327
- * capability. Mirrors `doorbellCapability.events.onPressed`. Carries
328
- * `{ deviceId, timestamp }`. Operators consuming the UI subscribe
329
- * here to render transient ring toasts and a "Recent presses" row
330
- * on the device detail page.
331
- */
332
- EventCategory["DoorbellOnPressed"] = "doorbell.onPressed";
333
- /**
334
- * Cap event fired by every device that registers the `event-emitter`
335
- * capability. Mirrors `eventEmitterCapability.events.onEvent`. Carries
336
- * `{ deviceId, eventType, data, timestamp, seq }` — the device's EXACT
337
- * declared event verbatim (NO normalization). Subscribers (UI event
338
- * stream, advanced-notifier rules) react to fired events without
339
- * holding a cap reference.
340
- */
341
- EventCategory["EventEmitted"] = "event-emitter.event";
342
- /**
343
- * Periodic snapshot of the per-node detection-pipeline engine
344
- * registry (loaded engines, models resident, in-use cameras, idle
345
- * TTL). Emitted ~0.2Hz (every 5 s) by every detection-pipeline
346
- * process. The Engines tab subscribes to drive its inventory view
347
- * without polling `pipelineExecutor.listLoadedEngines`.
348
- */
349
- EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
350
- /**
351
- * Per-node detection-engine runtime-provisioning transition. Emitted by
352
- * the detection-pipeline provider on every state change of its lazy
353
- * engine-provisioning machine (idle → installing → verifying → ready,
354
- * or → failed with a `nextRetryAt`). Payload is the
355
- * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
356
- * node. The Pipeline page subscribes to drive a live "installing
357
- * OpenVINO… / ready" indicator per node without polling
358
- * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
359
- * also reads the cap snapshot on mount / reconnect. Phase 2.
360
- */
361
- EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
362
- /**
363
- * Cluster topology snapshot. Carries the same payload returned by
364
- * `nodes.topology` (every reachable node + addons + processes).
365
- * Emitted by the hub on any agent / addon lifecycle change
366
- * (debounced) plus a periodic safety net. Replaces UI polling on
367
- * `nodes.topology` — admin-ui dashboards subscribe to drive the
368
- * cluster view directly from the event payload.
369
- */
370
- EventCategory["ClusterTopologySnapshot"] = "cluster.topology-snapshot";
371
- /**
372
- * Periodic per-node system metrics snapshot (CPU / memory / GPU /
373
- * disk / network). Emitted ~0.2 Hz by the metrics-provider addon
374
- * for each node. Drives the dashboard SystemStatus / ProcessResources
375
- * widgets without polling `metricsProvider.getCurrent`.
376
- */
377
- EventCategory["MetricsNodeResourcesSnapshot"] = "metrics.node-resources-snapshot";
378
- /**
379
- * Periodic per-node process-tree snapshot (camstack-related pids
380
- * with ghost / managed / root classification). Emitted ~0.2 Hz by
381
- * the metrics-provider addon. Drives the Cluster → Processes tab
382
- * without polling `metricsProvider.listNodeProcesses`.
383
- */
384
- EventCategory["MetricsNodeProcessesSnapshot"] = "metrics.node-processes-snapshot";
385
- /**
386
- * Capability binding change event emitted by `addon-pipeline-orchestrator`
387
- * when a user changes which addon implements a cap on a node. Subscribed
388
- * by every kernel process to update its local `preferredProviderRegistry`
389
- * so future capability lookups respect the new binding.
390
- */
391
- /**
392
- * A capability binding was changed for a node — addon X now provides
393
- * capability `cap` on node `nodeId`. Lives under the generic
394
- * `capability.*` namespace because capability bindings are a kernel-
395
- * level concept used by many addons, not strictly pipeline-scoped.
396
- */
397
- EventCategory["CapabilityBindingChanged"] = "capability.binding-changed";
398
- EventCategory["ModelDownloadProgress"] = "model.download.progress";
399
- EventCategory["AgentRegistered"] = "agent.registered";
400
- EventCategory["AgentUnregistered"] = "agent.unregistered";
401
- EventCategory["AgentOnline"] = "agent.online";
402
- EventCategory["AgentOffline"] = "agent.offline";
403
- /** Forked worker process (e.g. hub/pipeline) connected to the broker. */
404
- EventCategory["WorkerOnline"] = "worker.online";
405
- /** Forked worker process disconnected from the broker. */
406
- EventCategory["WorkerOffline"] = "worker.offline";
407
- EventCategory["AgentTaskDispatched"] = "agent.task.dispatched";
408
- EventCategory["AgentTaskAssigned"] = "agent.task.assigned";
409
- EventCategory["AgentTrpcConnected"] = "agent.trpc.connected";
410
- EventCategory["AgentWsConnected"] = "agent.ws.connected";
411
- EventCategory["AgentWsDisconnected"] = "agent.ws.disconnected";
412
- EventCategory["AgentBackupActivated"] = "agent.backup.activated";
413
- EventCategory["OrchestrationSettingsUpdated"] = "orchestration.settings-updated";
414
- /**
415
- * Per-agent hwaccel preference changed (user override set, cleared, or
416
- * re-probed). Observability event — decoders pull the current pref at
417
- * `createSession`, so running sessions keep their current backend until
418
- * they rotate naturally (camera add/remove, stream restart). Future
419
- * work can wire a listener in stream-broker that force-rotates live
420
- * sessions; for now this event powers logs + admin-UI toast feedback.
421
- */
422
- EventCategory["PipelineAgentHwaccelChanged"] = "pipeline.agent-hwaccel-changed";
423
- EventCategory["MotionAnalysis"] = "detection.motion-analysis";
424
- /** All raw motion zones from CCL before minArea filter — for UI debug overlay. */
425
- EventCategory["MotionZonesRaw"] = "detection.motion-zones-raw";
426
- /**
427
- * Per-camera motion phase transition (`watching ↔ active`) emitted
428
- * by the runner. Mirrors the `motion.onMotionChanged` cap event
429
- * surface — payload `MotionOnMotionChangedPayload` carries
430
- * `{deviceId, detected, timestamp, source, regions?}`. Subscribers
431
- * include addons that need to react to motion state without
432
- * polling the runtime-state mirror.
433
- */
434
- EventCategory["MotionOnMotionChanged"] = "motion.on-motion-changed";
435
- EventCategory["DetectionResult"] = "detection.result";
436
- EventCategory["DetectionRaw"] = "detection.raw";
437
- EventCategory["DetectionCameraNative"] = "detection.camera-native";
438
- /**
439
- * Canonical per-chunk live audio pipeline output. Payload is
440
- * `PipelineAudioInferenceResultPayload` carrying a full `AudioResult`
441
- * (level + detections + debug). Lives on the pipeline.* namespace
442
- * alongside `pipeline.inference-result` (video) for symmetry.
443
- */
444
- EventCategory["PipelineAudioInferenceResult"] = "pipeline.audio-inference-result";
445
- EventCategory["DetectionPhaseTransition"] = "detection.phase-transition";
446
- EventCategory["ProviderMotion"] = "provider.motion";
447
- EventCategory["ProviderDetection"] = "provider.detection";
448
- EventCategory["EnrichmentEmbeddingStored"] = "enrichment.embedding.stored";
449
- EventCategory["EnrichmentSceneStateChanged"] = "enrichment.scene.state-changed";
450
- EventCategory["EnrichmentActivitySummary"] = "enrichment.activity.summary";
451
- /**
452
- * Unified track-lifecycle event: ONE category carrying `phase:
453
- * 'start' | 'update' | 'end'` with a rich, typed
454
- * `PipelineAnalyticsTrackLifecyclePayload`. Supersedes the thin
455
- * `PipelineAnalyticsTrackStarted` / `PipelineAnalyticsTrackEnded`
456
- * pair — a consumer subscribes once and branches on `phase`.
457
- */
458
- EventCategory["PipelineAnalyticsTrackLifecycle"] = "pipeline-analytics.track-lifecycle";
459
- /**
460
- * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
461
- * (`phase:'start'`). Kept as a soft alias — no known subscribers.
462
- */
463
- EventCategory["PipelineAnalyticsTrackStarted"] = "pipeline-analytics.track-started";
464
- /**
465
- * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
466
- * (`phase:'end'`). Kept as a soft alias — no known subscribers.
467
- */
468
- EventCategory["PipelineAnalyticsTrackEnded"] = "pipeline-analytics.track-ended";
469
- EventCategory["PipelineAnalyticsDetectionEvent"] = "pipeline-analytics.detection-event";
470
- EventCategory["PipelineAnalyticsFrameTracked"] = "pipeline-analytics.frame-tracked";
471
- /**
472
- * Fired by `addon-post-analysis` when a parked (stationary) object appears
473
- * (a track was promoted to a stationary-registry entry) or departs (the
474
- * object moved / was removed). Telemetry (D8): lossy, drives a UI refresh of
475
- * the dedicated "Stationary" section — never the live event feed. Payload:
476
- * `{ deviceId, entryId, className, phase:'appeared'|'departed', timestamp }`.
477
- */
478
- EventCategory["PipelineAnalyticsStationaryChanged"] = "pipeline-analytics.stationary-changed";
479
- /**
480
- * Fired by `addon-post-analysis` when a package-drop is confirmed inside
481
- * a package zone — a newly-appeared stationary object of a package class
482
- * that cleared the class / zone / dwell / size gates. Payload:
483
- * `PipelineAnalyticsPackageDeliveredPayload` carrying `{ deviceId,
484
- * entryId, className, zoneIds, keyFrameMediaKey?, bbox, timestamp }`.
485
- * Telemetry (D8): the durable record is the `package-events` store row;
486
- * this bus topic drives notifier rules + live UI. See
487
- * docs/superpowers/specs/2026-07-17-package-zones-design.md §5.1.
488
- */
489
- EventCategory["PipelineAnalyticsPackageDelivered"] = "pipeline-analytics.package-delivered";
490
- /**
491
- * Fired by `addon-post-analysis` when a previously-delivered package
492
- * leaves its zone (the stationary entry departed — moved or swept).
493
- * Payload: `PipelineAnalyticsPackagePickedUpPayload` carrying
494
- * `{ deviceId, entryId, deliveredEventId, className, timestamp }`.
495
- * Telemetry (D8). See package-zones-design §5.2.
496
- */
497
- EventCategory["PipelineAnalyticsPackagePickedUp"] = "pipeline-analytics.package-picked-up";
498
- /**
499
- * Fired by `addon-post-analysis` whenever a gallery face row changes:
500
- * `kind:'buffered'` a new detected face was persisted, `'assigned'` /
501
- * `'unassigned'` its identity link changed, `'deleted'` the row was
502
- * removed. Telemetry semantics (D8): a lost event only delays a refresh,
503
- * never a correctness issue. Payload `PipelineAnalyticsFaceGalleryChangedPayload`.
504
- */
505
- EventCategory["PipelineAnalyticsFaceGalleryChanged"] = "pipeline-analytics.face-gallery-changed";
506
- /**
507
- * Fired by `addon-post-analysis` whenever a gallery plate row changes:
508
- * `kind:'buffered'` a new read was persisted (`PlateRecognizer.onTrackEnd`),
509
- * `'assigned'` / `'unassigned'` its vehicle link changed
510
- * (`PlateGalleryProvider`), `'deleted'` the row was removed. Telemetry
511
- * semantics (D8): a lost event only delays a refresh, never a correctness
512
- * issue. Payload `PipelineAnalyticsPlateGalleryChangedPayload`.
513
- */
514
- EventCategory["PipelineAnalyticsPlateGalleryChanged"] = "pipeline-analytics.plate-gallery-changed";
515
- EventCategory["FrigateLiveEvent"] = "frigate.live-event";
516
- EventCategory["CameraStreamsProfileSlotsChanged"] = "camera-streams.onProfileSlotsChanged";
517
- /**
518
- * Stream-broker health watchdog. Per-broker (deviceId/profile) emission.
519
- * `stream.offline` fires after STREAM_STALE_TIMEOUT_MS without an encoded
520
- * packet on an active broker. `stream.online` fires on first packet
521
- * after a stale gap (or on initial first packet). Payload includes
522
- * the assigned camStreamId as `profileKey`.
523
- */
524
- EventCategory["StreamOnline"] = "stream.online";
525
- EventCategory["StreamOffline"] = "stream.offline";
526
- EventCategory["NetworkTunnelStarted"] = "network.tunnel.started";
527
- EventCategory["NetworkTunnelStopped"] = "network.tunnel.stopped";
528
- /** Fired by the `local-network` cap when the host's interface set
529
- * changes (new IP from DHCP, VPN connect, docker bridge added). */
530
- EventCategory["LocalNetworkChanged"] = "network.local.changed";
531
- /**
532
- * Fired by a `mesh-network` provider (Tailscale, …) when its
533
- * mesh-reachable host changes (join / leave / MagicDNS or 100.x IP
534
- * change). Lets `local-network` fold the mesh endpoint into its
535
- * connection-endpoint list without a cross-package import. Payload
536
- * carries the preferred host (`host: ''` = no longer reachable),
537
- * the hub port, and the scheme. Telemetry-grade (D8): consumers
538
- * pull-reconcile from the provider's `getStatus` on reconnect. */
539
- EventCategory["MeshNetworkChanged"] = "network.mesh.changed";
540
- EventCategory["BackupCompleted"] = "backup.completed";
541
- EventCategory["BackupRestored"] = "backup.restored";
542
- EventCategory["NotificationDispatched"] = "notification.dispatched";
543
- EventCategory["NotificationFailed"] = "notification.failed";
544
- EventCategory["DeviceUpdated"] = "device.updated";
545
- /**
546
- * Transport-level connectivity. Emitted by the device driver when
547
- * the underlying control socket actually connects / disconnects
548
- * (Baichuan TCP, ONVIF probe response, RTSP DESCRIBE, …) — NOT for
549
- * power-state transitions on a battery camera. For battery wake /
550
- * doze cycles see `DeviceAwake` / `DeviceSleeping`.
551
- */
552
- EventCategory["DeviceOnline"] = "device.online";
553
- /**
554
- * Stream-broker watchdog — emitted when no encoded packet has been
555
- * received for STREAM_STALE_TIMEOUT_MS on an active broker (rtsp or
556
- * push). Paired with DeviceOnline which fires on first packet after
557
- * a stale gap. Payload: DeviceStreamHealthPayload.
558
- */
559
- EventCategory["DeviceOffline"] = "device.offline";
560
- /**
561
- * Battery cam woke up — physical power-state transition reported
562
- * by the device firmware. Distinct from `DeviceOnline` so a UI panel
563
- * watching power state doesn't flap on every UDP socket reconnect.
564
- */
565
- EventCategory["DeviceAwake"] = "device.awake";
566
- /**
567
- * Battery cam went to sleep. See `DeviceAwake`.
568
- */
569
- EventCategory["DeviceSleeping"] = "device.sleeping";
570
- EventCategory["RetentionCleanup"] = "retention.cleanup";
571
- /**
572
- * Legacy bulk-update progress snapshot (payload `BulkUpdateState`). No longer
573
- * emitted — F3 removed the coordinator that produced it; "Update all" now runs
574
- * as one lifecycle engine job (`AddonsJobProgress`/`AddonsJobLog`). Retained
575
- * (with `BulkUpdateState`) only to avoid regenerating the event maps; removed
576
- * in F4 once live bulk progress is re-implemented over the engine events.
577
- */
578
- EventCategory["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
579
- EventCategory["AddonsJobProgress"] = "addons.job-progress";
580
- EventCategory["AddonsJobLog"] = "addons.job-log";
581
- /**
582
- * A container's child visibility toggled (hidden/shown). Emitted by the
583
- * `accessories` cap when a child device is hidden or revealed.
584
- * Payload: `{ deviceId, childDeviceId, hidden }`.
585
- */
586
- EventCategory["AccessoriesChildVisibilityChanged"] = "accessories.onChildVisibilityChanged";
587
- /**
588
- * A container's child set changed (children added/removed/reordered).
589
- * Payload: `{ deviceId, childDeviceIds, hiddenChildIds }`.
590
- */
591
- EventCategory["AccessoriesChanged"] = "accessories.onAccessoriesChanged";
592
- /**
593
- * Progress update from a running model conversion job.
594
- * Payload: `{ kind: 'model-convert', phase, sessionId?, pct?, detail? }`.
595
- * Emitted by `addon-model-studio` on the converting node.
596
- */
597
- EventCategory["ModelConvertProgress"] = "model-convert.progress";
598
- return EventCategory;
599
- }({});
600
- //#endregion
601
2
  //#region ../../node_modules/zod/v4/core/core.js
602
3
  var _a$1;
603
4
  function $constructor(name, initializer, params) {
@@ -5253,6 +4654,604 @@ var ZodIssueCode = {
5253
4654
  /** @deprecated Do not use. Stub definition, only included for zod-to-json-schema compatibility. */
5254
4655
  var ZodFirstPartyTypeKind;
5255
4656
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
4657
+ //#endregion
4658
+ //#region ../types/dist/event-category-41fKf-q9.mjs
4659
+ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4660
+ EventCategory["SystemBoot"] = "system.boot";
4661
+ EventCategory["SystemAddonsReady"] = "system.addons-ready";
4662
+ EventCategory["SystemRestarting"] = "system.restarting";
4663
+ /**
4664
+ * Fired exactly once after the hub finishes booting following a
4665
+ * restart that was triggered by `RestartCoordinator` (today: framework
4666
+ * live-update). Payload is the marker that was on disk at boot
4667
+ * (`PendingRestartMarkerPayload`); admin UI listens for it to display a
4668
+ * success toast describing what changed.
4669
+ *
4670
+ * Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
4671
+ */
4672
+ EventCategory["SystemRestartCompleted"] = "system.restart-completed";
4673
+ /**
4674
+ * Readiness transition for a capability provider. Every producer emits
4675
+ * this event on `onInitialize` completion, `onDestroy`, and
4676
+ * `$node.reconnect`; every consumer that needs to gate on a cross-process
4677
+ * cap subscribes via the kernel's readiness module (`awaitReady` /
4678
+ * `onReadyState`) instead of polling. Payload is
4679
+ * `SystemReadyStatePayload` — see event-bus.ts.
4680
+ */
4681
+ EventCategory["SystemReadyState"] = "system.ready-state";
4682
+ EventCategory["AddonStarted"] = "addon.started";
4683
+ EventCategory["AddonStopped"] = "addon.stopped";
4684
+ EventCategory["AddonRestarted"] = "addon.restarted";
4685
+ EventCategory["AddonUpdated"] = "addon.updated";
4686
+ EventCategory["AddonInstalled"] = "addon.installed";
4687
+ EventCategory["AddonUninstalled"] = "addon.uninstalled";
4688
+ EventCategory["AddonCrashed"] = "addon.crashed";
4689
+ EventCategory["AddonError"] = "addon.error";
4690
+ EventCategory["AddonPageReady"] = "addon.page-ready";
4691
+ EventCategory["AddonWidgetReady"] = "addon.widget-ready";
4692
+ /**
4693
+ * Addon failed to load (import or initialize). Emitted by the kernel's
4694
+ * AddonHealthMonitor only AFTER the boot grace period ends — failures
4695
+ * during the first 5 minutes are silently retried without alerting
4696
+ * (slow-starting addons must have time to come up). Post-grace, this
4697
+ * event is emitted exactly once per failure-streak; AlertCenter
4698
+ * consumes it to create a persistent operator-visible alert.
4699
+ *
4700
+ * Payload: `{ packageName, addonId?, error: { message, stack }, retryCount, nextRetryAt }`.
4701
+ */
4702
+ EventCategory["AddonLoadFailed"] = "addon.load-failed";
4703
+ /**
4704
+ * Addon recovered from a previous failure. Emitted when an addon
4705
+ * transitions from `failed` back to `healthy` (typically via the
4706
+ * monitor's auto-retry loop, or after manual `addons.retryLoad`).
4707
+ * AlertCenter dismisses the corresponding `AddonLoadFailed` alert
4708
+ * on this event.
4709
+ */
4710
+ EventCategory["AddonLoadRecovered"] = "addon.load-recovered";
4711
+ /**
4712
+ * Monitor scheduled the next retry for a failed addon. Transient —
4713
+ * surfaced to the UI for live-updating the "next retry in Ns"
4714
+ * countdown on the Addons page row, NOT persisted as an alert.
4715
+ */
4716
+ EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
4717
+ /**
4718
+ * A liveness invariant of this node is false — it has no devices, every
4719
+ * camera is offline, or a camera that was recording has stopped producing
4720
+ * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
4721
+ * `findingId` is stable across ticks), only after its boot grace period.
4722
+ * AlertCenter raises a persistent operator-visible alert.
4723
+ *
4724
+ * It exists because on 2026-08-03 the hub ran four hours with zero devices
4725
+ * and zero recordings while every surface stayed quiet.
4726
+ *
4727
+ * Payload: `{ findingId, severity, title, message, deviceId? }`.
4728
+ */
4729
+ EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
4730
+ /**
4731
+ * A previously reported liveness fault is true again. AlertCenter dismisses
4732
+ * the matching `SystemLivenessFailed` alert.
4733
+ *
4734
+ * Payload: `{ findingId }`.
4735
+ */
4736
+ EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
4737
+ /**
4738
+ * Monitor is attempting to reload a failed addon NOW. UI uses this
4739
+ * to show a spinner during the retry attempt. Same transient nature
4740
+ * as AddonRetryScheduled.
4741
+ */
4742
+ EventCategory["AddonRetryAttempting"] = "addon.retry-attempting";
4743
+ EventCategory["DeviceRegistered"] = "device.registered";
4744
+ EventCategory["DeviceUnregistered"] = "device.unregistered";
4745
+ EventCategory["DeviceEnabled"] = "device.enabled";
4746
+ EventCategory["DeviceDisabled"] = "device.disabled";
4747
+ EventCategory["DeviceSettingsUpdated"] = "device.settings-updated";
4748
+ /**
4749
+ * Emitted when the set of native capability providers bound to a device
4750
+ * changes — e.g. an addon registers a new native cap via
4751
+ * `DeviceContext.registerNativeCap`, or all native bindings for a device
4752
+ * are cleared on removal. Hub consumers re-resolve device-proxy routes
4753
+ * when this fires.
4754
+ */
4755
+ EventCategory["DeviceBindingsChanged"] = "device.bindings-changed";
4756
+ /**
4757
+ * Emitted when the operator-organisational meta surface changes
4758
+ * (`name` / `location` / `disabled`). Payload: `{deviceId, field,
4759
+ * value}`. Live consumers (UI device list, alert center) react
4760
+ * without polling. Distinct from `DeviceSettingsUpdated` which
4761
+ * fires on hardware-config changes (host/port/credentials/etc).
4762
+ */
4763
+ EventCategory["DeviceMetaChanged"] = "device.meta-changed";
4764
+ /**
4765
+ * Emitted by `BaseDevice.updateSourceInfo()` after a successful patch
4766
+ * to the device's upstream-system identity / rendering envelope. The
4767
+ * full new SourceInfo travels in the payload so cross-process listeners
4768
+ * (UI, export adapters) don't need to re-read the meta blob.
4769
+ *
4770
+ * Payload: `{deviceId, sourceInfo}`.
4771
+ *
4772
+ * Distinct from `DeviceMetaChanged` which covers the operator-edited
4773
+ * surface (`name` / `location` / `disabled`). The two share the
4774
+ * persistence layer (both ride on `device-manager.setMetadata` for
4775
+ * SourceInfo, or `setName`/`setLocation`/`setDisabled` for the meta
4776
+ * fields) but consumers care about different subsets.
4777
+ */
4778
+ EventCategory["DeviceSourceInfoChanged"] = "device.source-info-changed";
4779
+ /**
4780
+ * Emitted by DeviceStreamWiringService after a successful
4781
+ * `stream-broker.registerDeviceStreams` call. Payload includes
4782
+ * deviceId — consumers look up the full registered device info via
4783
+ * `brokerManager.getRegisteredDevice(deviceId)`.
4784
+ *
4785
+ * Replaces the legacy `StreamRouterService.onDeviceRegistered` callback.
4786
+ */
4787
+ EventCategory["DeviceStreamsRegistered"] = "device.streams-registered";
4788
+ /**
4789
+ * Device-level "fully provisioned" signal — the EXPORT trigger. Emitted once
4790
+ * a device's persisted export-relevant shape (its `DeviceFeature` set) is
4791
+ * established or changes, carrying `{deviceId, fingerprint, generation}`.
4792
+ * Export adapters (Alexa / HAP) react to THIS instead of the chatty per-cap
4793
+ * `DeviceBindingsChanged`, turning a boot's incomplete→complete trickle into
4794
+ * one clean delta. `fingerprint` is `canonicalDeviceFingerprint(shape)`.
4795
+ *
4796
+ * Spec: docs/superpowers/specs/2026-06-01-alexa-hap-export-reconciler-design.md
4797
+ */
4798
+ EventCategory["DeviceProvisioned"] = "device.provisioned";
4799
+ /**
4800
+ * Fires once when a device's initial feature-probe completes successfully
4801
+ * (lastProbedAt 0→>0); exported shape is now stable. Telemetry (may be
4802
+ * dropped) — consumers MUST also gate on the device record's `probed` flag.
4803
+ */
4804
+ EventCategory["DeviceReady"] = "device.ready";
4805
+ EventCategory["IntegrationEnabled"] = "integration.enabled";
4806
+ EventCategory["IntegrationDisabled"] = "integration.disabled";
4807
+ EventCategory["IntegrationDeleted"] = "integration.deleted";
4808
+ /** Emitted when a broker connection's status flips (connected /
4809
+ * disconnected / error). Carries `{ brokerId, status, error? }`. */
4810
+ EventCategory["BrokerStatusChanged"] = "broker.status-changed";
4811
+ /** Emitted for every subscription-matched message routed by a
4812
+ * broker provider. Carries `{ brokerId, subscriptionId, key, payload }`.
4813
+ * Consumers filter by `brokerId` + `subscriptionId` in the handler. */
4814
+ EventCategory["BrokerMessage"] = "broker.message";
4815
+ EventCategory["ProviderStarted"] = "provider.started";
4816
+ EventCategory["ProviderStopped"] = "provider.stopped";
4817
+ EventCategory["ProcessCrashed"] = "process.crashed";
4818
+ EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
4819
+ EventCategory["ProcessRestarted"] = "process.restarted";
4820
+ EventCategory["RecordingStarted"] = "recording.started";
4821
+ EventCategory["RecordingStopped"] = "recording.stopped";
4822
+ EventCategory["RecordingError"] = "recording.error";
4823
+ EventCategory["RecordingHealthDegraded"] = "recording.health.degraded";
4824
+ EventCategory["RecordingStorageCritical"] = "recording.storage.critical";
4825
+ EventCategory["RecordingSegmentWritten"] = "recording.segment.written";
4826
+ EventCategory["RecordingPolicyFallback"] = "recording.policy.fallback";
4827
+ EventCategory["RecordingRetentionCompleted"] = "recording.retention.completed";
4828
+ /** Export render progress (0–100). Telemetry (D8): a lost tick is a stale
4829
+ * progress bar the client reconciles via `recordingExport.getExport`. */
4830
+ EventCategory["RecordingExportProgress"] = "recording.export.progress";
4831
+ EventCategory["RecordingExportCompleted"] = "recording.export.completed";
4832
+ EventCategory["RecordingExportFailed"] = "recording.export.failed";
4833
+ EventCategory["DetectionEvent"] = "detection.event";
4834
+ EventCategory["SessionTrackNew"] = "session.track.new";
4835
+ EventCategory["SessionTrackExpired"] = "session.track.expired";
4836
+ EventCategory["BenchmarkProgress"] = "benchmark.progress";
4837
+ EventCategory["PlatformProbePhase"] = "platform-probe.phase";
4838
+ EventCategory["PipelineProgress"] = "pipeline.progress";
4839
+ /** Per-frame execution trace emitted by the pipeline executor for live observability. */
4840
+ EventCategory["PipelineTrace"] = "pipeline.trace";
4841
+ /**
4842
+ * Raw inference output emitted by `addon-pipeline-runner` after running the
4843
+ * detection pipeline on a frame. Carries the `FrameResult` (with
4844
+ * `detections[]`, `width`/`height`, timing debug) — never the frame
4845
+ * buffer itself. Hub-side consumers (analysis pipeline, class filters,
4846
+ * notifications) subscribe to this and re-emit `detection.result` after
4847
+ * post-processing.
4848
+ */
4849
+ EventCategory["PipelineInferenceResult"] = "pipeline.inference-result";
4850
+ /**
4851
+ * Camera lifecycle event emitted by `addon-pipeline-orchestrator` when it
4852
+ * assigns or unassigns a camera to/from an agent. Carries no frame data;
4853
+ * pure observability for UI dashboards and metrics consumers.
4854
+ */
4855
+ EventCategory["PipelineCameraAssigned"] = "pipeline.camera-assigned";
4856
+ EventCategory["PipelineCameraUnassigned"] = "pipeline.camera-unassigned";
4857
+ /**
4858
+ * Per-camera pipeline config was mutated by the orchestrator
4859
+ * (3-level settings change via `setAgentAddonDefaults` /
4860
+ * `setCameraStepToggle` / `setCameraPipelineForAgent` or a
4861
+ * pipeline-scoped `applyDeviceSettingsPatch`). Orchestrator
4862
+ * subscribes to its own emission to hot-reload the assigned runner
4863
+ * via `attachCamera` so the next frame executes against the new
4864
+ * engine/steps without waiting for a rebalance or the next
4865
+ * `DeviceStreamsRegistered` cycle.
4866
+ */
4867
+ EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
4868
+ /**
4869
+ * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
4870
+ * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
4871
+ * capabilities — at boot, on agent online/offline, and on an ingest-node
4872
+ * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
4873
+ * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
4874
+ * cross-process `getIngestOwner` query (push the authority's decision instead
4875
+ * of polling it on the hot path). Idempotent state — re-emitted on every
4876
+ * topology change, so a dropped event self-heals on the next one (plus the
4877
+ * broker's long backstop reconcile query).
4878
+ */
4879
+ EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
4880
+ /**
4881
+ * Periodic snapshot of per-node pipeline-runner load
4882
+ * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
4883
+ * subscribe instead of polling `pipelineRunner.getLocalLoad`.
4884
+ * `nodeId` carried in the payload + on `event.source.nodeId`.
4885
+ */
4886
+ EventCategory["PipelineRunnerLoadSnapshot"] = "pipeline.runner-load-snapshot";
4887
+ /**
4888
+ * Periodic snapshot of per-camera pipeline metrics (`CameraMetrics`
4889
+ * + `deviceId` + `nodeId`). Emitted ~1Hz by every runner for each
4890
+ * attached camera. UI subscribes to drive overlay phase / fps /
4891
+ * inference time without polling `getCameraMetrics`.
4892
+ */
4893
+ EventCategory["PipelineCameraMetricsSnapshot"] = "pipeline.camera-metrics-snapshot";
4894
+ /**
4895
+ * Periodic snapshot of stream-broker per-broker statistics (input
4896
+ * fps, decoded fps, bitrate, codec). Emitted ~1Hz by every
4897
+ * stream-broker process for each active broker so the UI can drive
4898
+ * the Stream / Cluster dashboards without polling
4899
+ * `streamBroker.listAllProfileSlots` and friends.
4900
+ */
4901
+ EventCategory["StreamBrokerMetricsSnapshot"] = "stream-broker.metrics-snapshot";
4902
+ /**
4903
+ * Cap event fired by `stream-broker` when a profile slot enters
4904
+ * "demanded" state — a cam stream has been assigned and at least one
4905
+ * consumer (RTSP restream, decoded subscriber, WebRTC session, …) is
4906
+ * present. Camera-provider addons (Reolink Baichuan push, …)
4907
+ * subscribe to this category to start their underlying transport
4908
+ * lazily. Payload: `{ deviceId, camStreamId, profile }`.
4909
+ */
4910
+ EventCategory["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
4911
+ /**
4912
+ * Cap event fired by `stream-broker` when the last consumer leaves a
4913
+ * previously-demanded cam stream. Providers tear down their
4914
+ * underlying transport on receipt. Payload: `{ deviceId, camStreamId }`.
4915
+ */
4916
+ EventCategory["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
4917
+ /**
4918
+ * Cap event fired by `stream-broker` when a broker fails to dial a
4919
+ * managed-loopback source (today: `pull-rfc4571`) and the publisher
4920
+ * needs to refresh the cached URL. The lib's TCP server idle-tears-down
4921
+ * on its own schedule, so a re-publish with a fresh `host:port` is the
4922
+ * only way to keep the broker dialable. Camera providers (Reolink
4923
+ * Baichuan native, …) subscribe and respond by re-running their publish
4924
+ * pipeline.
4925
+ * Payload: `{ deviceId, camStreamId, brokerId }`.
4926
+ */
4927
+ EventCategory["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
4928
+ /**
4929
+ * A camera provider changed a device's stream parameters (codec /
4930
+ * resolution / bitrate / a stream added or removed). A LOW-LATENCY NUDGE
4931
+ * for the stream-broker's catalog reconcile: it carries NO authoritative
4932
+ * data — the broker re-PULLS that device's `stream-catalog` cap on receipt
4933
+ * (the 30s reconcile poll is the backstop if this nudge is dropped). Emitted
4934
+ * by providers from `stream-params.setProfile`. Payload: `{ deviceId }`.
4935
+ */
4936
+ EventCategory["StreamParamsChanged"] = "stream-params.changed";
4937
+ /**
4938
+ * Generic per-device runtime-state change. Fired by `device-manager`
4939
+ * whenever a persisted slice in any cap's `runtimeState` shape
4940
+ * mutates. Payload: `{deviceId, capName, slice}`. Subscribers are
4941
+ * the `deviceState` cap router (cross-process listeners) and the
4942
+ * deviceProxy reactive bindings (`device.state.<capName>.value`).
4943
+ * Cap-specific events (`battery.onStatusChanged`, …) still fire
4944
+ * — they're authoritative for callers that want a typed payload
4945
+ * without filtering on `capName`.
4946
+ */
4947
+ EventCategory["DeviceStateChanged"] = "device.state-changed";
4948
+ /**
4949
+ * Frame occupancy for a camera CHANGED — a tracked object was gained or
4950
+ * lost. Carries `{ deviceId, totalObjects, byClass, zones }`.
4951
+ *
4952
+ * Emitted only on a change, so a steady scene is silent. It exists so a
4953
+ * client can stop polling `zoneAnalytics.getCurrentSnapshot`: that was the
4954
+ * one live badge with no push signal at all, and it cost a request every
4955
+ * four seconds per visible camera.
4956
+ *
4957
+ * Like every event it is telemetry and may be dropped ([D8]) — a consumer
4958
+ * keeps a slow reconcile rather than trusting it alone.
4959
+ */
4960
+ EventCategory["ZoneAnalyticsOccupancyChanged"] = "zone-analytics.occupancy-changed";
4961
+ /**
4962
+ * Cap event fired by every device that registers the `battery`
4963
+ * capability. Mirrors the cap definition's `onStatusChanged`. Carries
4964
+ * `{ deviceId, status: BatteryStatus }`. Subscribers (alert center,
4965
+ * snapshot wrapper, UI) react to charge/sleep transitions without
4966
+ * polling `batteryCapability.getStatus`.
4967
+ */
4968
+ EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
4969
+ /**
4970
+ * Emitted by the battery cap provider WHEN `wakeForStream` enters the
4971
+ * "wake in progress" window — between the Baichuan wake-up issue and
4972
+ * the camera's first dialed-back RTP packet. The stream-broker
4973
+ * manager subscribes to flip the per-broker placeholder reason to
4974
+ * `'waking'` so viewers see a labelled "WAKING UP" tile instead of
4975
+ * the generic `'reconnecting'` frame. Carries `{ deviceId }`. The
4976
+ * complementary "wake complete" signal is the existing
4977
+ * `BatteryOnStatusChanged { sleeping: false }` event.
4978
+ */
4979
+ EventCategory["BatteryOnWakeStarted"] = "battery.onWakeStarted";
4980
+ /**
4981
+ * Cap event fired by every device that registers the `doorbell`
4982
+ * capability. Mirrors `doorbellCapability.events.onPressed`. Carries
4983
+ * `{ deviceId, timestamp }`. Operators consuming the UI subscribe
4984
+ * here to render transient ring toasts and a "Recent presses" row
4985
+ * on the device detail page.
4986
+ */
4987
+ EventCategory["DoorbellOnPressed"] = "doorbell.onPressed";
4988
+ /**
4989
+ * Cap event fired by every device that registers the `event-emitter`
4990
+ * capability. Mirrors `eventEmitterCapability.events.onEvent`. Carries
4991
+ * `{ deviceId, eventType, data, timestamp, seq }` — the device's EXACT
4992
+ * declared event verbatim (NO normalization). Subscribers (UI event
4993
+ * stream, advanced-notifier rules) react to fired events without
4994
+ * holding a cap reference.
4995
+ */
4996
+ EventCategory["EventEmitted"] = "event-emitter.event";
4997
+ /**
4998
+ * Periodic snapshot of the per-node detection-pipeline engine
4999
+ * registry (loaded engines, models resident, in-use cameras, idle
5000
+ * TTL). Emitted ~0.2Hz (every 5 s) by every detection-pipeline
5001
+ * process. The Engines tab subscribes to drive its inventory view
5002
+ * without polling `pipelineExecutor.listLoadedEngines`.
5003
+ */
5004
+ EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
5005
+ /**
5006
+ * Per-node detection-engine runtime-provisioning transition. Emitted by
5007
+ * the detection-pipeline provider on every state change of its lazy
5008
+ * engine-provisioning machine (idle → installing → verifying → ready,
5009
+ * or → failed with a `nextRetryAt`). Payload is the
5010
+ * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
5011
+ * node. The Pipeline page subscribes to drive a live "installing
5012
+ * OpenVINO… / ready" indicator per node without polling
5013
+ * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
5014
+ * also reads the cap snapshot on mount / reconnect. Phase 2.
5015
+ */
5016
+ EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
5017
+ /**
5018
+ * Cluster topology snapshot. Carries the same payload returned by
5019
+ * `nodes.topology` (every reachable node + addons + processes).
5020
+ * Emitted by the hub on any agent / addon lifecycle change
5021
+ * (debounced) plus a periodic safety net. Replaces UI polling on
5022
+ * `nodes.topology` — admin-ui dashboards subscribe to drive the
5023
+ * cluster view directly from the event payload.
5024
+ */
5025
+ EventCategory["ClusterTopologySnapshot"] = "cluster.topology-snapshot";
5026
+ /**
5027
+ * Periodic per-node system metrics snapshot (CPU / memory / GPU /
5028
+ * disk / network). Emitted ~0.2 Hz by the metrics-provider addon
5029
+ * for each node. Drives the dashboard SystemStatus / ProcessResources
5030
+ * widgets without polling `metricsProvider.getCurrent`.
5031
+ */
5032
+ EventCategory["MetricsNodeResourcesSnapshot"] = "metrics.node-resources-snapshot";
5033
+ /**
5034
+ * Periodic per-node process-tree snapshot (camstack-related pids
5035
+ * with ghost / managed / root classification). Emitted ~0.2 Hz by
5036
+ * the metrics-provider addon. Drives the Cluster → Processes tab
5037
+ * without polling `metricsProvider.listNodeProcesses`.
5038
+ */
5039
+ EventCategory["MetricsNodeProcessesSnapshot"] = "metrics.node-processes-snapshot";
5040
+ /**
5041
+ * Capability binding change event emitted by `addon-pipeline-orchestrator`
5042
+ * when a user changes which addon implements a cap on a node. Subscribed
5043
+ * by every kernel process to update its local `preferredProviderRegistry`
5044
+ * so future capability lookups respect the new binding.
5045
+ */
5046
+ /**
5047
+ * A capability binding was changed for a node — addon X now provides
5048
+ * capability `cap` on node `nodeId`. Lives under the generic
5049
+ * `capability.*` namespace because capability bindings are a kernel-
5050
+ * level concept used by many addons, not strictly pipeline-scoped.
5051
+ */
5052
+ EventCategory["CapabilityBindingChanged"] = "capability.binding-changed";
5053
+ EventCategory["ModelDownloadProgress"] = "model.download.progress";
5054
+ EventCategory["AgentRegistered"] = "agent.registered";
5055
+ EventCategory["AgentUnregistered"] = "agent.unregistered";
5056
+ EventCategory["AgentOnline"] = "agent.online";
5057
+ EventCategory["AgentOffline"] = "agent.offline";
5058
+ /** Forked worker process (e.g. hub/pipeline) connected to the broker. */
5059
+ EventCategory["WorkerOnline"] = "worker.online";
5060
+ /** Forked worker process disconnected from the broker. */
5061
+ EventCategory["WorkerOffline"] = "worker.offline";
5062
+ EventCategory["AgentTaskDispatched"] = "agent.task.dispatched";
5063
+ EventCategory["AgentTaskAssigned"] = "agent.task.assigned";
5064
+ EventCategory["AgentTrpcConnected"] = "agent.trpc.connected";
5065
+ EventCategory["AgentWsConnected"] = "agent.ws.connected";
5066
+ EventCategory["AgentWsDisconnected"] = "agent.ws.disconnected";
5067
+ EventCategory["AgentBackupActivated"] = "agent.backup.activated";
5068
+ EventCategory["OrchestrationSettingsUpdated"] = "orchestration.settings-updated";
5069
+ /**
5070
+ * Per-agent hwaccel preference changed (user override set, cleared, or
5071
+ * re-probed). Observability event — decoders pull the current pref at
5072
+ * `createSession`, so running sessions keep their current backend until
5073
+ * they rotate naturally (camera add/remove, stream restart). Future
5074
+ * work can wire a listener in stream-broker that force-rotates live
5075
+ * sessions; for now this event powers logs + admin-UI toast feedback.
5076
+ */
5077
+ EventCategory["PipelineAgentHwaccelChanged"] = "pipeline.agent-hwaccel-changed";
5078
+ EventCategory["MotionAnalysis"] = "detection.motion-analysis";
5079
+ /** All raw motion zones from CCL before minArea filter — for UI debug overlay. */
5080
+ EventCategory["MotionZonesRaw"] = "detection.motion-zones-raw";
5081
+ /**
5082
+ * Per-camera motion phase transition (`watching ↔ active`) emitted
5083
+ * by the runner. Mirrors the `motion.onMotionChanged` cap event
5084
+ * surface — payload `MotionOnMotionChangedPayload` carries
5085
+ * `{deviceId, detected, timestamp, source, regions?}`. Subscribers
5086
+ * include addons that need to react to motion state without
5087
+ * polling the runtime-state mirror.
5088
+ */
5089
+ EventCategory["MotionOnMotionChanged"] = "motion.on-motion-changed";
5090
+ EventCategory["DetectionResult"] = "detection.result";
5091
+ EventCategory["DetectionRaw"] = "detection.raw";
5092
+ EventCategory["DetectionCameraNative"] = "detection.camera-native";
5093
+ /**
5094
+ * Canonical per-chunk live audio pipeline output. Payload is
5095
+ * `PipelineAudioInferenceResultPayload` carrying a full `AudioResult`
5096
+ * (level + detections + debug). Lives on the pipeline.* namespace
5097
+ * alongside `pipeline.inference-result` (video) for symmetry.
5098
+ */
5099
+ EventCategory["PipelineAudioInferenceResult"] = "pipeline.audio-inference-result";
5100
+ EventCategory["DetectionPhaseTransition"] = "detection.phase-transition";
5101
+ EventCategory["ProviderMotion"] = "provider.motion";
5102
+ EventCategory["ProviderDetection"] = "provider.detection";
5103
+ EventCategory["EnrichmentEmbeddingStored"] = "enrichment.embedding.stored";
5104
+ EventCategory["EnrichmentSceneStateChanged"] = "enrichment.scene.state-changed";
5105
+ EventCategory["EnrichmentActivitySummary"] = "enrichment.activity.summary";
5106
+ /**
5107
+ * Unified track-lifecycle event: ONE category carrying `phase:
5108
+ * 'start' | 'update' | 'end'` with a rich, typed
5109
+ * `PipelineAnalyticsTrackLifecyclePayload`. Supersedes the thin
5110
+ * `PipelineAnalyticsTrackStarted` / `PipelineAnalyticsTrackEnded`
5111
+ * pair — a consumer subscribes once and branches on `phase`.
5112
+ */
5113
+ EventCategory["PipelineAnalyticsTrackLifecycle"] = "pipeline-analytics.track-lifecycle";
5114
+ /**
5115
+ * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
5116
+ * (`phase:'start'`). Kept as a soft alias — no known subscribers.
5117
+ */
5118
+ EventCategory["PipelineAnalyticsTrackStarted"] = "pipeline-analytics.track-started";
5119
+ /**
5120
+ * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
5121
+ * (`phase:'end'`). Kept as a soft alias — no known subscribers.
5122
+ */
5123
+ EventCategory["PipelineAnalyticsTrackEnded"] = "pipeline-analytics.track-ended";
5124
+ EventCategory["PipelineAnalyticsDetectionEvent"] = "pipeline-analytics.detection-event";
5125
+ EventCategory["PipelineAnalyticsFrameTracked"] = "pipeline-analytics.frame-tracked";
5126
+ /**
5127
+ * Fired by `addon-post-analysis` when a parked (stationary) object appears
5128
+ * (a track was promoted to a stationary-registry entry) or departs (the
5129
+ * object moved / was removed). Telemetry (D8): lossy, drives a UI refresh of
5130
+ * the dedicated "Stationary" section — never the live event feed. Payload:
5131
+ * `{ deviceId, entryId, className, phase:'appeared'|'departed', timestamp }`.
5132
+ */
5133
+ EventCategory["PipelineAnalyticsStationaryChanged"] = "pipeline-analytics.stationary-changed";
5134
+ /**
5135
+ * Fired by `addon-post-analysis` when a package-drop is confirmed inside
5136
+ * a package zone — a newly-appeared stationary object of a package class
5137
+ * that cleared the class / zone / dwell / size gates. Payload:
5138
+ * `PipelineAnalyticsPackageDeliveredPayload` carrying `{ deviceId,
5139
+ * entryId, className, zoneIds, keyFrameMediaKey?, bbox, timestamp }`.
5140
+ * Telemetry (D8): the durable record is the `package-events` store row;
5141
+ * this bus topic drives notifier rules + live UI. See
5142
+ * docs/superpowers/specs/2026-07-17-package-zones-design.md §5.1.
5143
+ */
5144
+ EventCategory["PipelineAnalyticsPackageDelivered"] = "pipeline-analytics.package-delivered";
5145
+ /**
5146
+ * Fired by `addon-post-analysis` when a previously-delivered package
5147
+ * leaves its zone (the stationary entry departed — moved or swept).
5148
+ * Payload: `PipelineAnalyticsPackagePickedUpPayload` carrying
5149
+ * `{ deviceId, entryId, deliveredEventId, className, timestamp }`.
5150
+ * Telemetry (D8). See package-zones-design §5.2.
5151
+ */
5152
+ EventCategory["PipelineAnalyticsPackagePickedUp"] = "pipeline-analytics.package-picked-up";
5153
+ /**
5154
+ * Fired by `addon-post-analysis` whenever a gallery face row changes:
5155
+ * `kind:'buffered'` a new detected face was persisted, `'assigned'` /
5156
+ * `'unassigned'` its identity link changed, `'deleted'` the row was
5157
+ * removed. Telemetry semantics (D8): a lost event only delays a refresh,
5158
+ * never a correctness issue. Payload `PipelineAnalyticsFaceGalleryChangedPayload`.
5159
+ */
5160
+ EventCategory["PipelineAnalyticsFaceGalleryChanged"] = "pipeline-analytics.face-gallery-changed";
5161
+ /**
5162
+ * Fired by `addon-post-analysis` whenever a gallery plate row changes:
5163
+ * `kind:'buffered'` a new read was persisted (`PlateRecognizer.onTrackEnd`),
5164
+ * `'assigned'` / `'unassigned'` its vehicle link changed
5165
+ * (`PlateGalleryProvider`), `'deleted'` the row was removed. Telemetry
5166
+ * semantics (D8): a lost event only delays a refresh, never a correctness
5167
+ * issue. Payload `PipelineAnalyticsPlateGalleryChangedPayload`.
5168
+ */
5169
+ EventCategory["PipelineAnalyticsPlateGalleryChanged"] = "pipeline-analytics.plate-gallery-changed";
5170
+ EventCategory["FrigateLiveEvent"] = "frigate.live-event";
5171
+ EventCategory["CameraStreamsProfileSlotsChanged"] = "camera-streams.onProfileSlotsChanged";
5172
+ /**
5173
+ * Stream-broker health watchdog. Per-broker (deviceId/profile) emission.
5174
+ * `stream.offline` fires after STREAM_STALE_TIMEOUT_MS without an encoded
5175
+ * packet on an active broker. `stream.online` fires on first packet
5176
+ * after a stale gap (or on initial first packet). Payload includes
5177
+ * the assigned camStreamId as `profileKey`.
5178
+ */
5179
+ EventCategory["StreamOnline"] = "stream.online";
5180
+ EventCategory["StreamOffline"] = "stream.offline";
5181
+ EventCategory["NetworkTunnelStarted"] = "network.tunnel.started";
5182
+ EventCategory["NetworkTunnelStopped"] = "network.tunnel.stopped";
5183
+ /** Fired by the `local-network` cap when the host's interface set
5184
+ * changes (new IP from DHCP, VPN connect, docker bridge added). */
5185
+ EventCategory["LocalNetworkChanged"] = "network.local.changed";
5186
+ /**
5187
+ * Fired by a `mesh-network` provider (Tailscale, …) when its
5188
+ * mesh-reachable host changes (join / leave / MagicDNS or 100.x IP
5189
+ * change). Lets `local-network` fold the mesh endpoint into its
5190
+ * connection-endpoint list without a cross-package import. Payload
5191
+ * carries the preferred host (`host: ''` = no longer reachable),
5192
+ * the hub port, and the scheme. Telemetry-grade (D8): consumers
5193
+ * pull-reconcile from the provider's `getStatus` on reconnect. */
5194
+ EventCategory["MeshNetworkChanged"] = "network.mesh.changed";
5195
+ EventCategory["BackupCompleted"] = "backup.completed";
5196
+ EventCategory["BackupRestored"] = "backup.restored";
5197
+ EventCategory["NotificationDispatched"] = "notification.dispatched";
5198
+ EventCategory["NotificationFailed"] = "notification.failed";
5199
+ EventCategory["DeviceUpdated"] = "device.updated";
5200
+ /**
5201
+ * Transport-level connectivity. Emitted by the device driver when
5202
+ * the underlying control socket actually connects / disconnects
5203
+ * (Baichuan TCP, ONVIF probe response, RTSP DESCRIBE, …) — NOT for
5204
+ * power-state transitions on a battery camera. For battery wake /
5205
+ * doze cycles see `DeviceAwake` / `DeviceSleeping`.
5206
+ */
5207
+ EventCategory["DeviceOnline"] = "device.online";
5208
+ /**
5209
+ * Stream-broker watchdog — emitted when no encoded packet has been
5210
+ * received for STREAM_STALE_TIMEOUT_MS on an active broker (rtsp or
5211
+ * push). Paired with DeviceOnline which fires on first packet after
5212
+ * a stale gap. Payload: DeviceStreamHealthPayload.
5213
+ */
5214
+ EventCategory["DeviceOffline"] = "device.offline";
5215
+ /**
5216
+ * Battery cam woke up — physical power-state transition reported
5217
+ * by the device firmware. Distinct from `DeviceOnline` so a UI panel
5218
+ * watching power state doesn't flap on every UDP socket reconnect.
5219
+ */
5220
+ EventCategory["DeviceAwake"] = "device.awake";
5221
+ /**
5222
+ * Battery cam went to sleep. See `DeviceAwake`.
5223
+ */
5224
+ EventCategory["DeviceSleeping"] = "device.sleeping";
5225
+ EventCategory["RetentionCleanup"] = "retention.cleanup";
5226
+ /**
5227
+ * Legacy bulk-update progress snapshot (payload `BulkUpdateState`). No longer
5228
+ * emitted — F3 removed the coordinator that produced it; "Update all" now runs
5229
+ * as one lifecycle engine job (`AddonsJobProgress`/`AddonsJobLog`). Retained
5230
+ * (with `BulkUpdateState`) only to avoid regenerating the event maps; removed
5231
+ * in F4 once live bulk progress is re-implemented over the engine events.
5232
+ */
5233
+ EventCategory["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
5234
+ EventCategory["AddonsJobProgress"] = "addons.job-progress";
5235
+ EventCategory["AddonsJobLog"] = "addons.job-log";
5236
+ /**
5237
+ * A container's child visibility toggled (hidden/shown). Emitted by the
5238
+ * `accessories` cap when a child device is hidden or revealed.
5239
+ * Payload: `{ deviceId, childDeviceId, hidden }`.
5240
+ */
5241
+ EventCategory["AccessoriesChildVisibilityChanged"] = "accessories.onChildVisibilityChanged";
5242
+ /**
5243
+ * A container's child set changed (children added/removed/reordered).
5244
+ * Payload: `{ deviceId, childDeviceIds, hiddenChildIds }`.
5245
+ */
5246
+ EventCategory["AccessoriesChanged"] = "accessories.onAccessoriesChanged";
5247
+ /**
5248
+ * Progress update from a running model conversion job.
5249
+ * Payload: `{ kind: 'model-convert', phase, sessionId?, pct?, detail? }`.
5250
+ * Emitted by `addon-model-studio` on the converting node.
5251
+ */
5252
+ EventCategory["ModelConvertProgress"] = "model-convert.progress";
5253
+ return EventCategory;
5254
+ }({});
5256
5255
  Object.fromEntries([
5257
5256
  {
5258
5257
  id: "overview",
@@ -6260,17 +6259,6 @@ var ProfileSlotSchema = object({
6260
6259
  errorMessage: string().optional()
6261
6260
  });
6262
6261
  /**
6263
- * The broker id keying a physical SOURCE stream: `${deviceId}/${camStreamId}`.
6264
- * This is the broker's internal source key (`brokerIdFor` in the manager) — a
6265
- * `camStreamId` like `native:main` — as opposed to the public profile alias
6266
- * {@link makeProfileBrokerId}. Used to resolve a source's restream endpoint
6267
- * (e.g. routing a transcode's ffmpeg input through the broker so it rides the
6268
- * single source dial instead of opening a second camera connection).
6269
- */
6270
- function makeSourceBrokerId(deviceId, camStreamId) {
6271
- return `${deviceId}/${camStreamId}`;
6272
- }
6273
- /**
6274
6262
  * Zod schema for StreamSourceEntry — the canonical stream descriptor
6275
6263
  * exposed by ICameraDevice.getStreamSources() and consumed by the broker.
6276
6264
  */
@@ -7087,6 +7075,14 @@ var EncodeProfileSchema = object({
7087
7075
  "main",
7088
7076
  "high"
7089
7077
  ]).optional(),
7078
+ /**
7079
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7080
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7081
+ * it, or it ships a stream that does not match its own advertisement — the
7082
+ * defect class that kept HomeKit black for a year and that Alexa carried
7083
+ * silently. Optional because a browser negotiates the level itself.
7084
+ */
7085
+ level: string().optional(),
7090
7086
  width: number().int().positive().optional(),
7091
7087
  height: number().int().positive().optional(),
7092
7088
  fps: number().positive().optional(),
@@ -7134,6 +7130,55 @@ var EncodeProfileSchema = object({
7134
7130
  outputArgs: array(string()).optional()
7135
7131
  });
7136
7132
  /**
7133
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7134
+ * Baseline because it is the one profile every consumer in this repo decodes
7135
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7136
+ */
7137
+ var BASE_LIVE_EGRESS_PROFILE = {
7138
+ video: {
7139
+ codec: "h264",
7140
+ profile: "baseline",
7141
+ level: "3.1",
7142
+ width: 1280,
7143
+ height: 720,
7144
+ fps: 25,
7145
+ bitrateKbps: 2500,
7146
+ gopFrames: 25,
7147
+ bf: 0,
7148
+ preset: "veryfast",
7149
+ tune: "zerolatency"
7150
+ },
7151
+ audio: "passthrough"
7152
+ };
7153
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7154
+ /**
7155
+ * Alexa's egress profile. Alexa's audio ALSO rides the WebRTC session's own
7156
+ * audio plane (out-of-band), so this is `passthrough` exactly like the browser
7157
+ * — see the ADR for why the previous in-band Opus was encoded and discarded.
7158
+ */
7159
+ /**
7160
+ * Alexa's egress asks for OPUS, and that is a change with a history.
7161
+ *
7162
+ * The previous profile encoded Opus IN-BAND into an MPEG-TS, where it was
7163
+ * discarded: Opus is `stream_type 0x06` and the broker's demuxer maps only
7164
+ * `0x0f` (aac), `0x03`/`0x04` (mp2) and `0x81` (ac3). Every frame it produced
7165
+ * died at the demuxer, so the encode was replaced with `passthrough` — correct,
7166
+ * because paying libopus for nothing is worse than silence.
7167
+ *
7168
+ * But `passthrough` means the egress emits NO audio at all, and Alexa's audio
7169
+ * does not arrive by magic: it rides the published stream, which is what the
7170
+ * WebRTC session dials. So the Echo had video and silence either way.
7171
+ *
7172
+ * Opus here now reaches the egress AUDIO SIDECAR — a separate RTP leg the
7173
+ * restreamer grafts onto its SDP — which never touches the MPEG-TS demuxer that
7174
+ * killed the in-band attempt. Same codec, different plane, and this one the
7175
+ * consumer can actually negotiate.
7176
+ */
7177
+ var ALEXA_EGRESS_PROFILE = {
7178
+ ...BASE_LIVE_EGRESS_PROFILE,
7179
+ audio: { codec: "opus" }
7180
+ };
7181
+ /**
7137
7182
  * Deep wiring healthcheck — snapshot of active reachability probes across
7138
7183
  * every declared capability + widget of every installed plugin, on every
7139
7184
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7183,6 +7228,154 @@ object({
7183
7228
  })
7184
7229
  });
7185
7230
  /**
7231
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7232
+ * pipeline functions an operator thinks in terms of.
7233
+ *
7234
+ * ## This file adds no state
7235
+ *
7236
+ * Every switch here is a VIEW onto an authority that already existed
7237
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7238
+ * group is that there is exactly one place each function is turned off, and
7239
+ * the group routes to it:
7240
+ *
7241
+ * | Switch | Authority | Proven "off stops the work" gate |
7242
+ * | --- | --- | --- |
7243
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7244
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7245
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7246
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7247
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7248
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7249
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7250
+ *
7251
+ * ## The two switches whose authority is not on this server
7252
+ *
7253
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7254
+ * in "the group stores nothing" — it is the purest form of it: the camera
7255
+ * holds the fact, every read is a read-through, and there is no server-side
7256
+ * copy that could drift. Their availability therefore cannot come from
7257
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7258
+ * is filtered out there); it comes from the cap's own camera-probed
7259
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7260
+ * THIS camera rather than for the device type
7261
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7262
+ *
7263
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7264
+ *
7265
+ * Every other switch means *this camera's function is doing its job*, so
7266
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7267
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7268
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7269
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7270
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7271
+ * worse than either surface not having one.
7272
+ *
7273
+ * Two consequences follow and both are load-bearing:
7274
+ *
7275
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7276
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7277
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7278
+ * normal, healthy state of most cameras rendered as an operator disablement.
7279
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7280
+ * unconditionally by both clients, so for this row it has to read correctly
7281
+ * whichever way the switch is sitting.
7282
+ *
7283
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7284
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7285
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7286
+ * surface that decision never got.
7287
+ *
7288
+ * ## Two rules that are load-bearing
7289
+ *
7290
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7291
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7292
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7293
+ * turning the camera back on would then silently record nothing.
7294
+ * - **A switch that is off must be reported as off**, not merely produce
7295
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7296
+ * "disabled by an operator" instead of "broken" — see
7297
+ * `CameraStatus.switchedOff`.
7298
+ */
7299
+ /**
7300
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7301
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7302
+ * recognition and plate/LPR are per-step toggles on
7303
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7304
+ * editor, not in a safety group.
7305
+ */
7306
+ var CameraSwitchIdSchema = _enum([
7307
+ "stream-broker",
7308
+ "object-detection",
7309
+ "privacy-mask",
7310
+ "device-audio",
7311
+ "audio-analysis",
7312
+ "recording",
7313
+ "notifications"
7314
+ ]);
7315
+ /**
7316
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7317
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7318
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7319
+ * a compile error rather than a review comment.
7320
+ */
7321
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7322
+ object({ kind: literal("device-disabled") }),
7323
+ object({
7324
+ kind: literal("wrapper-binding"),
7325
+ capName: string()
7326
+ }),
7327
+ object({ kind: literal("recording-config") }),
7328
+ object({ kind: literal("notification-mute") }),
7329
+ object({
7330
+ kind: literal("camera-audio"),
7331
+ capName: string()
7332
+ }),
7333
+ object({
7334
+ kind: literal("camera-mask"),
7335
+ capName: string()
7336
+ })
7337
+ ]);
7338
+ /**
7339
+ * Why a switch is not offered for this camera. Rendered instead of the
7340
+ * control, never as a dead control — an absent function and a broken one must
7341
+ * not look the same.
7342
+ */
7343
+ var CameraSwitchUnavailableReasonSchema = _enum([
7344
+ "no-provider",
7345
+ "source-unreachable",
7346
+ "not-configured"
7347
+ ]);
7348
+ /**
7349
+ * One switch, resolved for one camera.
7350
+ *
7351
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7352
+ * client-side: the viewer is a separate repository that does not import
7353
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7354
+ * that will disagree with itself. Five rows per camera is nothing.
7355
+ */
7356
+ var CameraSwitchSchema = object({
7357
+ id: CameraSwitchIdSchema,
7358
+ label: string(),
7359
+ /**
7360
+ * What the operator LOSES while this is off, in one sentence. Required, not
7361
+ * optional: a switch that cannot say what it costs should not ship.
7362
+ */
7363
+ costWhenOff: string(),
7364
+ /** False = do not render a control. `unavailableReason` says why. */
7365
+ available: boolean(),
7366
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7367
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7368
+ enabled: boolean(),
7369
+ authority: CameraSwitchAuthoritySchema
7370
+ });
7371
+ /** The whole group for one camera. */
7372
+ var CameraSwitchGroupSchema = object({
7373
+ deviceId: number().int(),
7374
+ switches: array(CameraSwitchSchema).readonly(),
7375
+ /** Unix ms when the group was composed server-side. */
7376
+ fetchedAt: number()
7377
+ });
7378
+ /**
7186
7379
  * Ops-log — the durable, append-only operations audit shared by the
7187
7380
  * recordings and events management surfaces.
7188
7381
  *
@@ -7201,14 +7394,16 @@ var OpsLogOpSchema = _enum([
7201
7394
  "manual-delete",
7202
7395
  "rescan",
7203
7396
  "retention-run",
7204
- "relocate"
7397
+ "relocate",
7398
+ "orphan-audit"
7205
7399
  ]);
7206
7400
  /** Why the operation ran. */
7207
7401
  var OpsLogReasonSchema = _enum([
7208
7402
  "retention",
7209
7403
  "quota",
7210
7404
  "manual",
7211
- "operator"
7405
+ "operator",
7406
+ "maintenance"
7212
7407
  ]);
7213
7408
  /** One audit row, shared verbatim by both domains. */
7214
7409
  var OpsLogEntrySchema = object({
@@ -9142,6 +9337,126 @@ var RtpSourceSchema = object({
9142
9337
  encoder: string(),
9143
9338
  pipelineKey: string()
9144
9339
  });
9340
+ /**
9341
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9342
+ * hatch.** This is deliberate and it is the one lesson taken from
9343
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9344
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9345
+ * adding a flag silently forks the shared child, and two consumers that mean
9346
+ * the same thing but spell it differently never share. Here every knob is a
9347
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9348
+ * never an opaque array.
9349
+ *
9350
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9351
+ * The operator-facing derived-stream transform editor still has them — that is
9352
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9353
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9354
+ */
9355
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9356
+ inputArgs: true,
9357
+ outputArgs: true
9358
+ });
9359
+ /**
9360
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9361
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9362
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9363
+ * an Echo). Named rather than numeric so the INTENT survives.
9364
+ */
9365
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9366
+ var EgressTranscodeRequestSchema = object({
9367
+ deviceId: number().int().nonnegative(),
9368
+ /** Which published stream to read. */
9369
+ source: discriminatedUnion("kind", [object({
9370
+ kind: literal("profile"),
9371
+ profile: CamProfileSchema
9372
+ }), object({
9373
+ kind: literal("cam-stream"),
9374
+ camStreamId: string().min(1)
9375
+ })]),
9376
+ encode: EgressEncodeSchema,
9377
+ rateControl: EgressRateControlSchema.optional(),
9378
+ /**
9379
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9380
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9381
+ * branches. Enumerated, not free text.
9382
+ */
9383
+ bitstreamFilter: _enum([
9384
+ "dump_extra",
9385
+ "h264_mp4toannexb",
9386
+ "hevc_mp4toannexb"
9387
+ ]).optional(),
9388
+ /**
9389
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9390
+ * consumer to dial the returned url. The broker picks the id and returns it
9391
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9392
+ * sharing key is computed FROM this request.
9393
+ *
9394
+ * The url is still returned and still the contract for a transcode pinned to
9395
+ * another node. But dialling it locally costs an RTSP round trip that changes
9396
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9397
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9398
+ * repacketizer branch. The push branch — the one the derived mechanism has
9399
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9400
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9401
+ * other point.
9402
+ *
9403
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9404
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9405
+ * This is that rule for media.
9406
+ */
9407
+ publishLocally: boolean().optional(),
9408
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9409
+ /**
9410
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9411
+ * and the one that matters: the broker then resolves the backend from the
9412
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9413
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9414
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9415
+ */
9416
+ decodeHwAccel: _enum([
9417
+ "auto",
9418
+ "none",
9419
+ "videotoolbox",
9420
+ "vaapi",
9421
+ "qsv",
9422
+ "cuda"
9423
+ ]).optional(),
9424
+ /**
9425
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9426
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9427
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9428
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9429
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9430
+ */
9431
+ hostname: string().optional(),
9432
+ /** Attribution for the broker panel. Never part of the sharing key. */
9433
+ tag: string().optional()
9434
+ });
9435
+ var EgressTranscodeSchema = object({
9436
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9437
+ url: string(),
9438
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9439
+ pipelineKey: string(),
9440
+ videoCodec: _enum(["H264", "H265"]),
9441
+ resolution: object({
9442
+ width: number().int().positive(),
9443
+ height: number().int().positive()
9444
+ }),
9445
+ transcoded: boolean(),
9446
+ encoder: string(),
9447
+ /**
9448
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9449
+ * Returned rather than assumed: a consumer that asked for hardware and got
9450
+ * software needs to be able to see that without reading the broker's logs.
9451
+ */
9452
+ decodeHwAccel: string().nullable(),
9453
+ /**
9454
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9455
+ * `url`, and the session takes the push/deframe transport rather than the
9456
+ * RTP-passthrough one. `null` means the consumer must dial.
9457
+ */
9458
+ camStreamId: string().nullable()
9459
+ });
9145
9460
  method(object({
9146
9461
  deviceId: number().int().nonnegative(),
9147
9462
  camStreamId: string().min(1),
@@ -9251,6 +9566,15 @@ method(object({
9251
9566
  }), {
9252
9567
  kind: "mutation",
9253
9568
  auth: "admin"
9569
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9570
+ kind: "mutation",
9571
+ auth: "admin"
9572
+ }), method(object({ pipelineKey: string() }), object({
9573
+ released: boolean(),
9574
+ refcount: number().int().nonnegative()
9575
+ }), {
9576
+ kind: "mutation",
9577
+ auth: "admin"
9254
9578
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9255
9579
  subscriptionId: string(),
9256
9580
  maxCount: number().int().positive().default(8)
@@ -13624,12 +13948,13 @@ var NcConditionsSchema = object({
13624
13948
  * source; otherwise the subject's source must equal it. Legacy records
13625
13949
  * with no stamped source are treated as `pipeline`. The union spans both
13626
13950
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13627
- * tracks carry `sensor`.
13951
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13628
13952
  */
13629
13953
  source: _enum([
13630
13954
  "pipeline",
13631
13955
  "onboard",
13632
13956
  "sensor",
13957
+ "audio",
13633
13958
  "any"
13634
13959
  ]).optional(),
13635
13960
  /**
@@ -14205,6 +14530,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14205
14530
  }), object({ success: literal(true) }), {
14206
14531
  kind: "mutation",
14207
14532
  auth: "admin"
14533
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14534
+ deviceId: number().int(),
14535
+ muted: boolean()
14536
+ }), object({ success: literal(true) }), {
14537
+ kind: "mutation",
14538
+ auth: "admin"
14208
14539
  }), method(object({
14209
14540
  rule: NcRuleInputSchema,
14210
14541
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14549,12 +14880,60 @@ var TrackAudioLabelSchema = object({
14549
14880
  });
14550
14881
  /**
14551
14882
  * How a track was produced. `pipeline` (default / absent) = the spatial
14552
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14553
- * linked sensor/control state change (no positions; carries a snapshot). The
14554
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14555
- * resurrection) MUST skip `sensor` tracks — they have no bbox trajectory.
14883
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection —
14884
+ * no positions, a single snapshot, and no bbox trajectory at all:
14885
+ *
14886
+ * - `sensor` — a linked sensor/control device state change.
14887
+ * - `audio` — an audio event on the camera itself that was anomalous for
14888
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
14889
+ *
14890
+ * The spatial subsystems (tracker association, occupancy count, re-id /
14891
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
14892
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
14893
+ * check silently readmits every source added after it was written.
14894
+ */
14895
+ var TrackSourceSchema = _enum([
14896
+ "pipeline",
14897
+ "sensor",
14898
+ "audio"
14899
+ ]);
14900
+ /**
14901
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14902
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14903
+ * so the two surfaces cannot drift.
14904
+ *
14905
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
14906
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
14907
+ * columns existed read as absent, and a consumer that needs a boolean should say
14908
+ * `flag === true`, not `flag !== false`.
14909
+ *
14910
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14911
+ * operator curation, and the behaviour they drive will be specified separately.
14912
+ * In particular a `markForTrain` track is NOT pinned against retention — see
14913
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
14556
14914
  */
14557
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
14915
+ var TrackFlagFields = {
14916
+ /** Operator marked this track as training material. */
14917
+ markForTrain: boolean().optional(),
14918
+ /** Operator marked this track for diagnostic attention. */
14919
+ debug: boolean().optional()
14920
+ };
14921
+ /**
14922
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14923
+ * one flag can never clear the other — the toggles are independent and are
14924
+ * driven from three surfaces that do not know about each other.
14925
+ */
14926
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
14927
+ /**
14928
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
14929
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
14930
+ * mutation result without a re-fetch.
14931
+ */
14932
+ var TrackFlagsSchema = object({
14933
+ trackId: string(),
14934
+ markForTrain: boolean(),
14935
+ debug: boolean()
14936
+ });
14558
14937
  var TrackSchema = object({
14559
14938
  trackId: string(),
14560
14939
  deviceId: number(),
@@ -14597,7 +14976,8 @@ var TrackSchema = object({
14597
14976
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14598
14977
  * Populated from the persisted envelope columns on historical reads;
14599
14978
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14600
- envelope: TrackEnvelopeSchema.optional()
14979
+ envelope: TrackEnvelopeSchema.optional(),
14980
+ ...TrackFlagFields
14601
14981
  });
14602
14982
  var BaseEventFields = {
14603
14983
  id: string(),
@@ -14810,7 +15190,8 @@ var KeyEventSchema = object({
14810
15190
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
14811
15191
  bestEventId: string(),
14812
15192
  /** Track lifetime in ms (lastSeen - firstSeen). */
14813
- windowMs: number().optional()
15193
+ windowMs: number().optional(),
15194
+ ...TrackFlagFields
14814
15195
  });
14815
15196
  object({
14816
15197
  trackId: string(),
@@ -14895,16 +15276,32 @@ var RebuildObjectEmbeddingsInput = object({
14895
15276
  deviceId: number().optional(),
14896
15277
  since: number().optional(),
14897
15278
  until: number().optional(),
15279
+ /** Stop after this many tracks; the result reports whether more remain. */
15280
+ maxTracks: number().int().positive().optional(),
14898
15281
  /**
14899
- * Fraction added on EACH side of the detection box before cropping.
14900
- * ~0.2 (a 1.4x window) gives CLIP the surroundings it is trained on; 0 is
14901
- * the pixel-tight crop the first implementation used.
15282
+ * Run every embedding on THIS node instead of round-robining the fleet.
15283
+ *
15284
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15285
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15286
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15287
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15288
+ * remotely. This field is data; the per-track pin is applied inside.
15289
+ *
15290
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15291
+ * pinned model.
14902
15292
  */
14903
- cropMargin: number().min(0).max(4).optional(),
14904
- /** Square the window before extracting, so a tall subject is not squashed. */
14905
- square: boolean().optional(),
14906
- /** Stop after this many tracks; the result reports whether more remain. */
14907
- maxTracks: number().int().positive().optional()
15293
+ executeOnNodeId: string().optional(),
15294
+ /**
15295
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15296
+ * run flat out.
15297
+ *
15298
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15299
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15300
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15301
+ * force is logged at start and finish so a deliberately slow pass reads
15302
+ * differently from a stalled one.
15303
+ */
15304
+ pacingMs: number().int().nonnegative().optional()
14908
15305
  });
14909
15306
  /**
14910
15307
  * Result of emptying the CLIP index.
@@ -14937,6 +15334,24 @@ var RebuildStatusSchema = object({
14937
15334
  missingKeyFrame: number(),
14938
15335
  /** Tracks with no usable detection box. */
14939
15336
  missingBbox: number(),
15337
+ /**
15338
+ * Tracks an executing node REFUSED rather than broke on — an unreadable key
15339
+ * frame, a step that threw. Separate from `failed` because the remedy is
15340
+ * different, and because a whole camera silently contributing zero vectors
15341
+ * is the shape of failure a rebuild must never hide.
15342
+ */
15343
+ notRunnable: number(),
15344
+ /**
15345
+ * The pass stopped because NO node could serve the pinned model.
15346
+ *
15347
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15348
+ * refused", this one says "the cluster cannot do this work at all" — every
15349
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15350
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15351
+ * engine change, not a per-camera one. Non-zero here always comes with
15352
+ * `complete: false`.
15353
+ */
15354
+ noCapableNode: number(),
14940
15355
  failed: number(),
14941
15356
  /** Set once a pass ends: true only when EVERYTHING was covered. */
14942
15357
  complete: boolean().nullable(),
@@ -15008,7 +15423,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15008
15423
  }), {
15009
15424
  kind: "mutation",
15010
15425
  auth: "admin"
15011
- }), method(object({}), EventStoreFootprintSchema, {
15426
+ }), method(object({
15427
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15428
+ deviceId: number(),
15429
+ trackId: string(),
15430
+ flags: TrackFlagsPatchSchema
15431
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
15012
15432
  kind: "query",
15013
15433
  auth: "admin"
15014
15434
  }), method(object({
@@ -15618,6 +16038,53 @@ var DetailResultSchema = object({
15618
16038
  nativeFaceShortSidePx: number().optional()
15619
16039
  });
15620
16040
  /**
16041
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
16042
+ *
16043
+ * A refusal is a first-class answer, not an error, because the caller's next
16044
+ * move depends on WHICH one it is — and because "the pass produced nothing"
16045
+ * must never be reachable without a named, counted cause. The two tiers:
16046
+ *
16047
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
16048
+ * never serve this (step, model) pair. The caller drops it from its rotation
16049
+ * and retries the same work elsewhere; nothing about the work changes.
16050
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
16051
+ * fine, this one request is not. Retrying it on another node would only
16052
+ * spread the same failure.
16053
+ */
16054
+ var StatelessStepRefusalSchema = _enum([
16055
+ "unknown-step",
16056
+ "model-not-servable",
16057
+ "unreadable-frame",
16058
+ "execution-failed"
16059
+ ]);
16060
+ /**
16061
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
16062
+ * result, because `null` is exactly what made the camera-bound detail path
16063
+ * unable to tell "refused" from "never asked".
16064
+ */
16065
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
16066
+ kind: literal("ran"),
16067
+ /** The node that actually executed it — the pin, echoed back for the log. */
16068
+ nodeId: string(),
16069
+ /**
16070
+ * The model the step ran with.
16071
+ *
16072
+ * The node verified this exact id has a build for the format it dispatched
16073
+ * on BEFORE running, so the executor's format resolution returns it
16074
+ * unchanged. A caller that pinned a model must compare this field and
16075
+ * treat a mismatch as a refusal — the whole point of the pin is that a
16076
+ * pass writes one feature space.
16077
+ */
16078
+ modelId: string(),
16079
+ details: array(DetailResultSchema)
16080
+ }), object({
16081
+ kind: literal("refused"),
16082
+ nodeId: string(),
16083
+ reason: StatelessStepRefusalSchema,
16084
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
16085
+ detail: string()
16086
+ })]);
16087
+ /**
15621
16088
  * Per-camera tunable ranges + defaults. Single source of truth used
15622
16089
  * by both the Zod data schema (validation + default fallback) and
15623
16090
  * the device settings UI (slider min/max/step). Touch one place and
@@ -15953,10 +16420,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15953
16420
  }), NativeCropResultSchema.nullable()), method(object({
15954
16421
  deviceId: number(),
15955
16422
  frameHandle: FrameHandleSchema.optional(),
16423
+ /**
16424
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16425
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16426
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16427
+ */
16428
+ frameJpeg: string().optional(),
16429
+ /**
16430
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16431
+ * The fallback when the lease/session backing the frame is gone and the
16432
+ * caller already holds a crop.
16433
+ */
15956
16434
  cropJpeg: string().optional(),
15957
16435
  parent: DetailParentSchema,
15958
16436
  steps: array(string()).optional()
15959
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16437
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16438
+ /** Catalog step id, e.g. `clip-embedding`. */
16439
+ stepId: string(),
16440
+ /**
16441
+ * REQUIRED model pin. The node runs this exact model or refuses with
16442
+ * `model-not-servable` — it never substitutes a format default, because
16443
+ * a fleet pass that round-robins across nodes would then fill one index
16444
+ * from several encoders.
16445
+ */
16446
+ modelId: string(),
16447
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16448
+ frameJpeg: string(),
16449
+ /**
16450
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16451
+ * purpose: the caller stores boxes against a downscaled analysis frame
16452
+ * while the stored key frame is native-resolution, and the only side
16453
+ * that reliably knows the image's pixel dimensions is the side that
16454
+ * decodes it. Denormalising here removes a second reader of the
16455
+ * dimensions and the class of mismatch that comes with it.
16456
+ */
16457
+ bbox: NativeCropBboxSchema,
16458
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16459
+ className: string(),
16460
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16461
+ sourceDeviceId: number()
16462
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
15960
16463
  var CameraPipelineConfigSchema = object({
15961
16464
  engine: PipelineEngineChoiceSchema.optional(),
15962
16465
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16254,6 +16757,20 @@ var CameraStatusSchema = object({
16254
16757
  detection: CameraDetectionStatusSchema.nullable(),
16255
16758
  audio: CameraAudioStatusSchema.nullable(),
16256
16759
  recording: CameraRecordingStatusSchema.nullable(),
16760
+ /**
16761
+ * Per-camera function switches an OPERATOR has turned off
16762
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
16763
+ *
16764
+ * This is the difference between DISABLED and BROKEN. A camera whose
16765
+ * `detection` block reports zero fps and whose `switchedOff` contains
16766
+ * `'object-detection'` was switched off by a person; the same camera with an
16767
+ * empty list is failing. Every status surface must render the two
16768
+ * differently — a quiet camera that looks identical to a dead one is the
16769
+ * silence-reads-as-never-happened trap this repo keeps paying for.
16770
+ *
16771
+ * Empty when nothing is off. Never contains a switch no provider offers.
16772
+ */
16773
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16257
16774
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16258
16775
  fetchedAt: number()
16259
16776
  });
@@ -16422,7 +16939,14 @@ method(object({
16422
16939
  }), method(object({
16423
16940
  deviceId: number(),
16424
16941
  agentNodeId: string().optional()
16425
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16942
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
16943
+ deviceId: number(),
16944
+ switchId: CameraSwitchIdSchema,
16945
+ enabled: boolean()
16946
+ }), CameraSwitchGroupSchema, {
16947
+ kind: "mutation",
16948
+ auth: "admin"
16949
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16426
16950
  name: string(),
16427
16951
  description: string().optional(),
16428
16952
  config: CameraPipelineConfigSchema
@@ -16745,9 +17269,15 @@ DeviceType.Camera, method(object({
16745
17269
  * Bypass the cache freshness check and fetch directly from the
16746
17270
  * native (or stream-broker fallback). Triggered by the UI's
16747
17271
  * "refresh" button so an operator can force a fresh frame
16748
- * even when the cache is well within `snapshotMaxAgeMs`.
16749
- * On battery cams this WILL wake the camera — accept the
16750
- * cost only when the user explicitly asks for it.
17272
+ * even when the cache is well within the device's
17273
+ * `snapshotMaxAgeS` window.
17274
+ *
17275
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
17276
+ * battery camera it is the one thing that walks past the wrapper's
17277
+ * sleep gate and wakes the camera, so a background caller — a poller,
17278
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
17279
+ * gets the cached frame, which on a sleeping battery camera is the
17280
+ * correct answer: stale but honest beats woken.
16751
17281
  */
16752
17282
  force: boolean().optional()
16753
17283
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -21029,12 +21559,30 @@ object({
21029
21559
  });
21030
21560
  DeviceType.Sensor;
21031
21561
  /**
21032
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
21033
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
21034
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
21035
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
21036
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
21037
- * normalized 0..1 (top-left origin).
21562
+ * PRIVACY — what the camera deliberately does not capture. Two planes:
21563
+ *
21564
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
21565
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
21566
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
21567
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
21568
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
21569
+ * normalized 0..1 (top-left origin).
21570
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
21571
+ * camera encoding an audio track at all, so EVERY consumer — live view,
21572
+ * recording, the audio analyzer, an export — sees silent video. There is
21573
+ * no server-side copy of this fact; the camera is the store and every read
21574
+ * is a read-through, which is why a switch over it cannot drift
21575
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
21576
+ *
21577
+ * Both belong here for one reason: they are the two things an operator turns
21578
+ * off when the answer to "what is this camera allowed to record" changes, and
21579
+ * both are applied ON the device, before anything leaves it.
21580
+ *
21581
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
21582
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
21583
+ * by one provider — and it was removed when this landed. A second writer onto
21584
+ * one device register is the shape of every knob this repo has shipped that
21585
+ * disagreed with the one the reader read.
21038
21586
  */
21039
21587
  /** A privacy-mask region's geometry — rectangle or free polygon. */
21040
21588
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -21050,16 +21598,40 @@ object({
21050
21598
  enabled: boolean(),
21051
21599
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
21052
21600
  regions: array(PrivacyMaskRegionSchema),
21601
+ /**
21602
+ * Is the camera capturing sound right now? Read from the camera, never from
21603
+ * a server-side mirror.
21604
+ *
21605
+ * `null` means "no answer" — either this camera exposes no controllable
21606
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
21607
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
21608
+ * "the microphone is off" and "we could not ask" look identical to an
21609
+ * operator only until one of them is wrong.
21610
+ *
21611
+ * On a camera whose profiles carry the flag independently (Reolink writes
21612
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
21613
+ * privacy is only satisfied when every one of them is silent.
21614
+ */
21615
+ audioEnabled: boolean().nullable(),
21053
21616
  lastFetchedAt: number()
21054
21617
  });
21055
- /** Per-camera availability. */
21618
+ /** Per-camera availability. Probed, never assumed from the model name. */
21056
21619
  var PrivacyMaskOptionsSchema = object({
21057
21620
  /** Maximum number of supported zones. */
21058
21621
  maxRegions: number(),
21059
21622
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
21060
21623
  supportedShapes: array(MaskShapeKindSchema),
21061
21624
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
21062
- polygonVertices: MaskPolygonVerticesSchema.optional()
21625
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
21626
+ /**
21627
+ * Does this camera expose a microphone switch we can actually write?
21628
+ *
21629
+ * Camera-probed: `true` only when the firmware answered with an audio flag
21630
+ * we know how to patch. A camera that never answered is `false` — a control
21631
+ * the operator can press that changes nothing is worse than no control, and
21632
+ * the switch group renders "not available" instead.
21633
+ */
21634
+ supportsAudioMute: boolean()
21063
21635
  });
21064
21636
  /** Partial change — every field optional. */
21065
21637
  var PrivacyMaskPatchSchema = object({
@@ -21072,6 +21644,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
21072
21644
  }), _void(), {
21073
21645
  kind: "mutation",
21074
21646
  auth: "admin"
21647
+ }), method(object({
21648
+ deviceId: number(),
21649
+ enabled: boolean()
21650
+ }), _void(), {
21651
+ kind: "mutation",
21652
+ auth: "admin"
21075
21653
  });
21076
21654
  var PtzPresetSchema = object({
21077
21655
  id: string(),
@@ -21281,6 +21859,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
21281
21859
  })]);
21282
21860
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
21283
21861
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
21862
+ /**
21863
+ * One GOP of a finalized segment, cut by byte range through the segment's own
21864
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
21865
+ * the single `moof`+`mdat` covering the requested instant — standalone-
21866
+ * demuxable, never the whole file. When the segment's index cannot be parsed
21867
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
21868
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
21869
+ */
21870
+ var ReadGopBytesResultSchema = object({
21871
+ data: _instanceof(Uint8Array),
21872
+ /** Absolute epoch ms of the returned fragment's first sample. */
21873
+ gopStartMs: number(),
21874
+ /** Media ms the returned fragment covers. */
21875
+ gopDurMs: number()
21876
+ });
21284
21877
  method(object({
21285
21878
  deviceId: number(),
21286
21879
  fromMs: number(),
@@ -21323,6 +21916,14 @@ method(object({
21323
21916
  }), ReadSegmentBytesResultSchema, {
21324
21917
  kind: "query",
21325
21918
  auth: "admin"
21919
+ }), method(object({
21920
+ deviceId: number(),
21921
+ profile: string(),
21922
+ startMs: number(),
21923
+ epochMs: number()
21924
+ }), ReadGopBytesResultSchema, {
21925
+ kind: "query",
21926
+ auth: "admin"
21326
21927
  }), method(object({
21327
21928
  deviceId: number(),
21328
21929
  config: RecordingConfigSchema
@@ -21803,6 +22404,16 @@ var StreamProfileConfigSchema = object({
21803
22404
  "baseline"
21804
22405
  ]).optional(),
21805
22406
  gop: number().optional(),
22407
+ /**
22408
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
22409
+ *
22410
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
22411
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
22412
+ * writes every profile at once so "audio off" means silent everywhere. A
22413
+ * per-profile writer beside it would let a camera be half-muted and would be
22414
+ * a second knob onto one device register — the failure D62 exists to
22415
+ * prevent. Absent when the firmware does not report the flag.
22416
+ */
21806
22417
  audio: boolean().optional()
21807
22418
  });
21808
22419
  object({
@@ -21843,7 +22454,13 @@ var StreamParamsOptionsSchema = object({
21843
22454
  ext: StreamProfileOptionsSchema.optional()
21844
22455
  });
21845
22456
  /** A partial change to one profile — every field optional; a provider
21846
- * ignores fields it doesn't support. */
22457
+ * ignores fields it doesn't support.
22458
+ *
22459
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
22460
+ * reachable from no form and honoured by exactly one provider, while the
22461
+ * camera's microphone is a whole-device fact. It now has one writer,
22462
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
22463
+ * `privacy-mask.cap.ts`. */
21847
22464
  var StreamProfilePatchSchema = object({
21848
22465
  width: number().optional(),
21849
22466
  height: number().optional(),
@@ -21856,8 +22473,7 @@ var StreamProfilePatchSchema = object({
21856
22473
  "main",
21857
22474
  "baseline"
21858
22475
  ]).optional(),
21859
- gop: number().optional(),
21860
- audio: boolean().optional()
22476
+ gop: number().optional()
21861
22477
  });
21862
22478
  DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
21863
22479
  deviceId: number(),
@@ -25379,6 +25995,12 @@ Object.freeze({
25379
25995
  addonId: null,
25380
25996
  access: "view"
25381
25997
  },
25998
+ "notificationRules.listDeviceMutes": {
25999
+ capName: "notification-rules",
26000
+ capScope: "system",
26001
+ addonId: null,
26002
+ access: "view"
26003
+ },
25382
26004
  "notificationRules.listRules": {
25383
26005
  capName: "notification-rules",
25384
26006
  capScope: "system",
@@ -25397,6 +26019,12 @@ Object.freeze({
25397
26019
  addonId: null,
25398
26020
  access: "create"
25399
26021
  },
26022
+ "notificationRules.setDeviceMuted": {
26023
+ capName: "notification-rules",
26024
+ capScope: "system",
26025
+ addonId: null,
26026
+ access: "create"
26027
+ },
25400
26028
  "notificationRules.setRuleEnabled": {
25401
26029
  capName: "notification-rules",
25402
26030
  capScope: "system",
@@ -25667,11 +26295,17 @@ Object.freeze({
25667
26295
  addonId: null,
25668
26296
  access: "create"
25669
26297
  },
25670
- "pipelineAnalytics.searchObjectEvents": {
26298
+ "pipelineAnalytics.searchObjectEvents": {
26299
+ capName: "pipeline-analytics",
26300
+ capScope: "device",
26301
+ addonId: null,
26302
+ access: "view"
26303
+ },
26304
+ "pipelineAnalytics.setTrackFlags": {
25671
26305
  capName: "pipeline-analytics",
25672
26306
  capScope: "device",
25673
26307
  addonId: null,
25674
- access: "view"
26308
+ access: "create"
25675
26309
  },
25676
26310
  "pipelineAnalytics.wipeAllAnalytics": {
25677
26311
  capName: "pipeline-analytics",
@@ -25979,6 +26613,12 @@ Object.freeze({
25979
26613
  addonId: null,
25980
26614
  access: "view"
25981
26615
  },
26616
+ "pipelineOrchestrator.getCameraSwitches": {
26617
+ capName: "pipeline-orchestrator",
26618
+ capScope: "system",
26619
+ addonId: null,
26620
+ access: "view"
26621
+ },
25982
26622
  "pipelineOrchestrator.getCapabilityBindings": {
25983
26623
  capName: "pipeline-orchestrator",
25984
26624
  capScope: "system",
@@ -26111,6 +26751,12 @@ Object.freeze({
26111
26751
  addonId: null,
26112
26752
  access: "create"
26113
26753
  },
26754
+ "pipelineOrchestrator.setCameraSwitch": {
26755
+ capName: "pipeline-orchestrator",
26756
+ capScope: "system",
26757
+ addonId: null,
26758
+ access: "create"
26759
+ },
26114
26760
  "pipelineOrchestrator.setCapabilityBinding": {
26115
26761
  capName: "pipeline-orchestrator",
26116
26762
  capScope: "system",
@@ -26201,6 +26847,12 @@ Object.freeze({
26201
26847
  addonId: null,
26202
26848
  access: "create"
26203
26849
  },
26850
+ "pipelineRunner.runStatelessStep": {
26851
+ capName: "pipeline-runner",
26852
+ capScope: "system",
26853
+ addonId: null,
26854
+ access: "create"
26855
+ },
26204
26856
  "plateGallery.assignPlate": {
26205
26857
  capName: "plate-gallery",
26206
26858
  capScope: "system",
@@ -26333,6 +26985,12 @@ Object.freeze({
26333
26985
  addonId: null,
26334
26986
  access: "view"
26335
26987
  },
26988
+ "privacyMask.setAudioEnabled": {
26989
+ capName: "privacy-mask",
26990
+ capScope: "device",
26991
+ addonId: null,
26992
+ access: "create"
26993
+ },
26336
26994
  "privacyMask.setMask": {
26337
26995
  capName: "privacy-mask",
26338
26996
  capScope: "device",
@@ -26501,6 +27159,12 @@ Object.freeze({
26501
27159
  addonId: null,
26502
27160
  access: "create"
26503
27161
  },
27162
+ "recording.readGopBytes": {
27163
+ capName: "recording",
27164
+ capScope: "system",
27165
+ addonId: null,
27166
+ access: "view"
27167
+ },
26504
27168
  "recording.readSegmentBytes": {
26505
27169
  capName: "recording",
26506
27170
  capScope: "system",
@@ -27017,6 +27681,12 @@ Object.freeze({
27017
27681
  addonId: null,
27018
27682
  access: "create"
27019
27683
  },
27684
+ "streamBroker.acquireEgressTranscode": {
27685
+ capName: "stream-broker",
27686
+ capScope: "system",
27687
+ addonId: null,
27688
+ access: "create"
27689
+ },
27020
27690
  "streamBroker.assignProfile": {
27021
27691
  capName: "stream-broker",
27022
27692
  capScope: "system",
@@ -27125,6 +27795,12 @@ Object.freeze({
27125
27795
  addonId: null,
27126
27796
  access: "create"
27127
27797
  },
27798
+ "streamBroker.releaseEgressTranscode": {
27799
+ capName: "stream-broker",
27800
+ capScope: "system",
27801
+ addonId: null,
27802
+ access: "create"
27803
+ },
27128
27804
  "streamBroker.releaseStreamWithCodec": {
27129
27805
  capName: "stream-broker",
27130
27806
  capScope: "system",
@@ -27885,6 +28561,112 @@ TimelapseRuleInputSchema.extend({
27885
28561
  createdAt: number(),
27886
28562
  updatedAt: number()
27887
28563
  });
28564
+ object({
28565
+ /**
28566
+ * Fraction of the box's own size added on EACH side before cutting.
28567
+ *
28568
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
28569
+ * removes exactly the context it is strongest on (a dog cut to its outline
28570
+ * is a dark blob). The right value is an empirical question, which is why it
28571
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
28572
+ */
28573
+ paddingRatio: number().min(0).max(4),
28574
+ /**
28575
+ * Square the window (in PIXELS) before cutting.
28576
+ *
28577
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
28578
+ * — a standing person becomes a shape the model never saw. Squaring costs
28579
+ * extra background, which is context the model wants anyway. Off by default
28580
+ * because the live path has never squared and the stored index reflects that.
28581
+ */
28582
+ square: boolean()
28583
+ });
28584
+ ({
28585
+ paddingRatio: .15,
28586
+ square: false
28587
+ }).paddingRatio;
28588
+ /**
28589
+ * WHICH delivered frames the decode worker retains a native copy of.
28590
+ *
28591
+ * - `all` — every frame the worker delivered to the runner. The shipped
28592
+ * behaviour, and the only correct one if something can ask for a crop of a
28593
+ * frame the runner never sent to inference.
28594
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
28595
+ * A native-crop request always names a `frameId` that rode an inference
28596
+ * result, so that is the only set a request can name. How much it drops is
28597
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
28598
+ * this cluster, not the ~80% the design sketch assumed, because the governor
28599
+ * was not throttling as hard as the sketch supposed. Read
28600
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
28601
+ * of you rather than quoting a number from here. The newest delivered frame is
28602
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
28603
+ * which covers the one-frame race between a mark and the supersede that
28604
+ * consumes it.
28605
+ */
28606
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
28607
+ object({
28608
+ /**
28609
+ * How long a retained native frame is served before it counts as a miss.
28610
+ *
28611
+ * Must cover the FULL late-crop horizon: detection inference + the
28612
+ * cross-process inference-result hop to hub post-analysis + tracking + the
28613
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
28614
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
28615
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
28616
+ */
28617
+ ttlMs: number().int().min(250).max(1e4),
28618
+ /**
28619
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
28620
+ *
28621
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
28622
+ * which one is actually binding before reasoning from that. At the shipped
28623
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
28624
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
28625
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
28626
+ * change that admits fewer frames buys retention WINDOW at constant RAM
28627
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
28628
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
28629
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
28630
+ * to replace).
28631
+ */
28632
+ budgetMb: number().int().min(0).max(4096),
28633
+ /**
28634
+ * Demand window: eager per-frame native retention runs only within this many
28635
+ * ms of the last native-crop request (or of the dial starting).
28636
+ *
28637
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
28638
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
28639
+ * per second on a camera with zero crop demand), so leave it non-zero unless
28640
+ * you are reproducing that.
28641
+ */
28642
+ activityMs: number().int().min(0).max(12e4),
28643
+ /**
28644
+ * Which delivered frames are retained at all — see
28645
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
28646
+ * changes WHAT is kept rather than for how long, so it is also the only one
28647
+ * that can turn a crop that used to hit into a miss. The worker counts every
28648
+ * crop request naming a frame it did NOT see marked
28649
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
28650
+ * there is the signal that some caller names frames outside the inference set
28651
+ * and that this must go back to `all`.
28652
+ */
28653
+ admission: NativeLeaseAdmissionSchema
28654
+ });
28655
+ /**
28656
+ * The values in force when the operator has set nothing — byte-for-byte the
28657
+ * constants the decode worker shipped with as env-var defaults, so making these
28658
+ * settings changed no behaviour on the day it landed.
28659
+ */
28660
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
28661
+ ttlMs: 1200,
28662
+ budgetMb: 1024,
28663
+ activityMs: 15e3,
28664
+ admission: "inferred"
28665
+ };
28666
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
28667
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
28668
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
28669
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
27888
28670
  //#endregion
27889
28671
  //#region src/custom-actions.ts
27890
28672
  /**
@@ -27916,6 +28698,87 @@ var exportAlexaActions = defineCustomActions({
27916
28698
  }), { kind: "mutation" })
27917
28699
  });
27918
28700
  //#endregion
28701
+ //#region src/egress/alexa-rtc-egress.ts
28702
+ /**
28703
+ * Acquire Alexa's encoded stream and make it dialable by a WebRTC session.
28704
+ *
28705
+ * The request carries NO raw ffmpeg flags — the primitive's schema has no
28706
+ * escape hatch, on purpose: an opaque `outputArgs` array would be both the
28707
+ * extensibility mechanism AND part of the sharing key, so one extra flag would
28708
+ * silently fork the shared child and two consumers meaning the same thing but
28709
+ * spelling it differently would never share.
28710
+ *
28711
+ * Placement note: the call is UNPINNED, so `classifyCapRoute` serves it
28712
+ * hub-in-process. Moving Alexa's transcode to another node is a `nodePin` here
28713
+ * plus a `hostname` this addon can dial — nothing else in this function
28714
+ * assumes hub-locality.
28715
+ */
28716
+ async function acquireAlexaEgress(input) {
28717
+ const { api, deviceId, sourceProfile, sessionId } = input;
28718
+ const transcode = await api.streamBroker.acquireEgressTranscode.mutate({
28719
+ deviceId,
28720
+ source: {
28721
+ kind: "profile",
28722
+ profile: sourceProfile
28723
+ },
28724
+ encode: ALEXA_EGRESS_PROFILE,
28725
+ rateControl: "relaxed",
28726
+ publishLocally: true,
28727
+ tag: `alexa/${sessionId}`
28728
+ });
28729
+ const camStreamId = transcode.camStreamId;
28730
+ if (camStreamId === null) {
28731
+ await api.streamBroker.releaseEgressTranscode.mutate({ pipelineKey: transcode.pipelineKey }).catch(() => void 0);
28732
+ input.logger?.warn("alexa egress: broker did not publish locally — released the handle", {
28733
+ tags: {
28734
+ deviceId,
28735
+ sessionId
28736
+ },
28737
+ meta: { pipelineKey: transcode.pipelineKey }
28738
+ });
28739
+ throw new Error("alexa egress: broker declined to publish the transcode locally");
28740
+ }
28741
+ input.logger?.info("alexa egress: acquired through the ffmpeg primitive", {
28742
+ tags: {
28743
+ deviceId,
28744
+ sessionId
28745
+ },
28746
+ meta: {
28747
+ pipelineKey: transcode.pipelineKey,
28748
+ camStreamId,
28749
+ encoder: transcode.encoder,
28750
+ decodeHwAccel: transcode.decodeHwAccel ?? "software"
28751
+ }
28752
+ });
28753
+ return {
28754
+ camStreamId,
28755
+ pipelineKey: transcode.pipelineKey,
28756
+ transcode
28757
+ };
28758
+ }
28759
+ /**
28760
+ * Drop this session's reference, and retract the published stream once the LAST
28761
+ * holder has gone.
28762
+ *
28763
+ * Never throws. Alexa expects an `Alexa.Response` to `SessionDisconnected`, and
28764
+ * a stale handle after a broker respawn is a normal event — turning either into
28765
+ * an error envelope would make a clean teardown look like a fault.
28766
+ */
28767
+ async function releaseAlexaEgress(input) {
28768
+ const { api, deviceId, pipelineKey } = input;
28769
+ try {
28770
+ await api.streamBroker.releaseEgressTranscode.mutate({ pipelineKey });
28771
+ } catch (err) {
28772
+ input.logger?.warn("alexa egress: release failed — the handle may leak until the broker restarts", {
28773
+ tags: { deviceId },
28774
+ meta: {
28775
+ pipelineKey,
28776
+ error: errMsg(err)
28777
+ }
28778
+ });
28779
+ }
28780
+ }
28781
+ //#endregion
27919
28782
  //#region src/handlers/types.ts
27920
28783
  /**
27921
28784
  * Zod schemas for the subset of the Alexa Smart Home directive surface
@@ -28629,169 +29492,7 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
28629
29492
  } };
28630
29493
  }
28631
29494
  //#endregion
28632
- //#region src/egress/alexa-rtc-egress.ts
28633
- /**
28634
- * `AlexaRtcEgress` — placeholder `RtpEgress` adapter for the Alexa RTC
28635
- * session path.
28636
- *
28637
- * Today Alexa RTC works by handing the SDP offer received in the
28638
- * `Alexa.RTCSessionController.InitiateSessionWithOffer` directive to the
28639
- * `webrtc-session` cap's `handleOffer` mutation. That call lives inside
28640
- * `addon-pipeline`, runs through the broker-resident `AdaptiveSession`,
28641
- * and ultimately wires the same DTLS-SRTP-over-werift egress as the
28642
- * browser viewer. The Alexa path is therefore "WebRTC over a different
28643
- * signalling channel" — the egress is functionally `WebRtcEgress`.
28644
- *
28645
- * This wrapper exists for two reasons:
28646
- * 1. So the four export targets (WebRTC viewer, HomeKit, Alexa, future
28647
- * SRTP-out) each have an `RtpEgress`-typed entry point. The fan-out
28648
- * loop in the broker is then symmetric — every consumer is "an
28649
- * `RtpEgress` someone started".
28650
- * 2. So the typechecker can keep us honest as the shared-transcode
28651
- * refactor lands. Once the broker exposes a stream-source that fan-
28652
- * outs to N egresses, the Alexa handler will switch from "post the
28653
- * offer to webrtcSession.handleOffer" to "build a local
28654
- * AdaptiveSession + register an `AlexaRtcEgress` with the broker".
28655
- *
28656
- * The class itself is therefore intentionally minimal: it accepts the
28657
- * `webrtc-session` session handle from the handler, exposes the
28658
- * RtpEgress contract, and delegates pushVideo/pushAudio to a no-op. The
28659
- * real RTP flow remains owned by the AdaptiveSession the cap created
28660
- * for us — none of the bytes flow through this object today.
28661
- *
28662
- * Derived-stream helpers
28663
- * ─────────────────────
28664
- * `computeAlexaDerivedName` and `getOrSpawnAlexaDerivedBroker` (exported
28665
- * below) implement the derived-broker spawn path. The serve path
28666
- * MUST call `getOrSpawnAlexaDerivedBroker` instead of reaching the broker
28667
- * directly so that identical EncodeProfile params share a single ffmpeg
28668
- * instance (single-flight via a deterministic brokerId).
28669
- *
28670
- */
28671
- /**
28672
- * Wrapper around an already-negotiated `webrtc-session` (created by the
28673
- * Alexa directive handler via `webrtcSession.handleOffer`). The
28674
- * AdaptiveSession owns the RTP pump, so this adapter is a no-op for
28675
- * `pushVideoRtp` / `pushAudioRtp`. The `stop()` method calls back into
28676
- * the directive handler so the underlying cap session is torn down
28677
- * when an upstream consumer disposes the egress.
28678
- */
28679
- var AlexaRtcEgress = class {
28680
- id;
28681
- logger;
28682
- closeSessionRemote;
28683
- started = false;
28684
- stopped = false;
28685
- constructor(opts) {
28686
- this.id = opts.id;
28687
- this.logger = opts.logger;
28688
- this.closeSessionRemote = opts.closeSessionRemote;
28689
- }
28690
- async start(opts) {
28691
- if (this.started) return;
28692
- if (this.stopped) throw new Error("AlexaRtcEgress: cannot restart after stop()");
28693
- this.started = true;
28694
- this.logger.debug("AlexaRtcEgress started", { meta: { egressId: this.id } });
28695
- }
28696
- pushVideoRtp(rtpPacket) {}
28697
- pushAudioRtp(rtpPacket) {}
28698
- async stop() {
28699
- if (this.stopped) return;
28700
- this.stopped = true;
28701
- this.started = false;
28702
- try {
28703
- await this.closeSessionRemote();
28704
- } catch (err) {
28705
- this.logger.warn("AlexaRtcEgress closeSessionRemote threw", { meta: {
28706
- egressId: this.id,
28707
- error: errMsg(err)
28708
- } });
28709
- }
28710
- }
28711
- };
28712
- /**
28713
- * Derives a deterministic, human-readable name for an Alexa derived
28714
- * stream from the device id + encode profile. Identical params always
28715
- * produce the same name so the broker can reuse a running instance.
28716
- *
28717
- * Format: `alexa-<deviceId>-<sha1[0..8]>`
28718
- */
28719
- function computeAlexaDerivedName(deviceId, profile) {
28720
- return `alexa-${deviceId}-${createHash("sha1").update(JSON.stringify(profile)).digest("hex").slice(0, 8)}`;
28721
- }
28722
- /**
28723
- * The single encode profile used for every Alexa derived stream. The
28724
- * operator-facing encode-profile config was removed, so this constant is
28725
- * the sole profile passed to `getOrSpawnAlexaDerivedBroker`. Alexa requires
28726
- * Opus audio; H.264 baseline keeps compatibility with Echo Show hardware
28727
- * decoders.
28728
- *
28729
- * Intentionally duplicated here so `addon-export-alexa` has no dependency
28730
- * on any other addon's preset registry (cross-addon imports are forbidden).
28731
- * These values mirror the `alexa-baseline` preset in the stream-broker; the
28732
- * broker-side preset is authoritative for broker scheduling while this
28733
- * constant is the local fallback for the Alexa egress path only.
28734
- */
28735
- var ALEXA_FALLBACK_ENCODE_PROFILE = {
28736
- video: {
28737
- codec: "h264",
28738
- profile: "baseline",
28739
- width: 1280,
28740
- height: 720,
28741
- fps: 25,
28742
- bitrateKbps: 2500,
28743
- gopFrames: 25,
28744
- bf: 0,
28745
- preset: "veryfast",
28746
- tune: "zerolatency"
28747
- },
28748
- audio: {
28749
- codec: "opus",
28750
- bitrateKbps: 64,
28751
- sampleRateHz: 48e3,
28752
- channels: 1
28753
- }
28754
- };
28755
- /**
28756
- * Resolves the source camStreamId for the requested profile slot, then
28757
- * calls `publishCameraStream({ kind: 'derived', … })` on the broker.
28758
- *
28759
- * The broker is idempotent for identical `(camStreamId, encodeProfile)`
28760
- * pairs, so calling this multiple times for the same session is safe.
28761
- *
28762
- * Throws `ALEXA_NO_SOURCE_FOR_PROFILE` if the slot has no source assigned.
28763
- */
28764
- async function getOrSpawnAlexaDerivedBroker(opts) {
28765
- const slot = (await opts.api.streamBroker.listAllProfileSlots.query()).find((s) => s.deviceId === opts.deviceId && s.profile === opts.sourceProfile);
28766
- if (slot?.sourceCamStreamId == null) throw new Error(`ALEXA_NO_SOURCE_FOR_PROFILE: profile=${opts.sourceProfile} deviceId=${opts.deviceId}`);
28767
- const camStreamId = `derived:${computeAlexaDerivedName(opts.deviceId, opts.encodeProfile)}`;
28768
- await opts.api.streamBroker.publishCameraStream.mutate({
28769
- deviceId: opts.deviceId,
28770
- camStreamId,
28771
- kind: "derived",
28772
- sourceCamStreamId: slot.sourceCamStreamId,
28773
- encodeProfile: opts.encodeProfile
28774
- });
28775
- return { brokerId: makeSourceBrokerId(opts.deviceId, camStreamId) };
28776
- }
28777
- //#endregion
28778
29495
  //#region src/handlers/rtc-session.ts
28779
- /**
28780
- * `Alexa.RTCSessionController` handler.
28781
- *
28782
- * Alexa's flow is "client (Echo / Alexa Cloud) sends the SDP offer →
28783
- * skill returns the answer", the dual of our default `createSession`
28784
- * flow (server offers first). The `webrtc-session` device cap exposes
28785
- * `handleOffer({ deviceId, sdpOffer, sessionId? })` for exactly this
28786
- * direction; this dispatcher just translates Alexa's directive
28787
- * envelope to/from the cap call.
28788
- *
28789
- * Echo / Alexa Cloud will send `InitiateSessionWithOffer` when an
28790
- * RTSP fallback isn't preferred (Show 10/15, voice "show me"
28791
- * commands). On disconnect, Alexa sends `SessionDisconnected` and we
28792
- * tear the session down via `closeSession` so the broker stops
28793
- * forwarding frames.
28794
- */
28795
29496
  var InitiateSessionWithOfferDirectiveSchema = object({
28796
29497
  endpoint: EndpointRefSchema,
28797
29498
  payload: InitiateSessionWithOfferPayloadSchema
@@ -28800,6 +29501,23 @@ var SessionDisconnectedDirectiveSchema = object({
28800
29501
  endpoint: EndpointRefSchema,
28801
29502
  payload: SessionDisconnectedPayloadSchema
28802
29503
  });
29504
+ /**
29505
+ * Live egress handles, keyed by `<deviceId>/<sessionId>`.
29506
+ *
29507
+ * The primitive's refcount has no lease TTL: a holder that vanishes without
29508
+ * releasing leaks a child until the broker restarts (D67). This handler is a
29509
+ * holder, so it keeps the one piece of state that lets it not be that holder —
29510
+ * the `pipelineKey` its `SessionDisconnected` has to release.
29511
+ *
29512
+ * A module-level map is right here because the directive handlers are free
29513
+ * functions called per directive; the entry is written on a successful acquire
29514
+ * and deleted on the release path, both of which are the same tick as the
29515
+ * directive that owns it.
29516
+ */
29517
+ var EGRESS_HANDLES = /* @__PURE__ */ new Map();
29518
+ function egressHandleKey(deviceId, sessionId) {
29519
+ return `${deviceId}/${sessionId}`;
29520
+ }
28803
29521
  var WEBRTC_PROFILE_NAMES = [
28804
29522
  "high",
28805
29523
  "mid",
@@ -28842,7 +29560,6 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28842
29560
  if (pref === "low" || pref === "mid" || pref === "high") return pref;
28843
29561
  })();
28844
29562
  const sourceProfile = explicitSourceProfile ?? mappedFromPreference ?? "high";
28845
- const encodeProfile = ALEXA_FALLBACK_ENCODE_PROFILE;
28846
29563
  try {
28847
29564
  if (isSleepingBatteryStatus(null)) {
28848
29565
  const wakeStart = Date.now();
@@ -28866,6 +29583,13 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28866
29583
  return value["sleeping"] === true;
28867
29584
  }
28868
29585
  let derivedTarget;
29586
+ /**
29587
+ * The primitive's release handle, when this session acquired one. Recorded so
29588
+ * `SessionDisconnected` can release it: a refcount whose holder disappears is
29589
+ * the one gap D67 leaves open, and the least this handler can do is not BE
29590
+ * that holder.
29591
+ */
29592
+ let egressPipelineKey = null;
28869
29593
  const picked = await api.cameraStreams.pickStream.query({
28870
29594
  deviceId,
28871
29595
  requirements: {
@@ -28889,18 +29613,21 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28889
29613
  camStreamId: picked.camStreamId
28890
29614
  };
28891
29615
  } else try {
28892
- const { brokerId } = await getOrSpawnAlexaDerivedBroker({
29616
+ const acquired = await acquireAlexaEgress({
28893
29617
  api,
28894
29618
  deviceId,
28895
29619
  sourceProfile,
28896
- encodeProfile
29620
+ sessionId,
29621
+ logger: log
28897
29622
  });
29623
+ egressPipelineKey = acquired.pipelineKey;
29624
+ EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
28898
29625
  derivedTarget = {
28899
29626
  kind: "cam-stream",
28900
- camStreamId: brokerId.split("/").slice(1).join("/")
29627
+ camStreamId: acquired.camStreamId
28901
29628
  };
28902
29629
  } catch (err) {
28903
- log.warn("alexa rtc-session: derived spawn failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
29630
+ log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
28904
29631
  derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
28905
29632
  }
28906
29633
  try {
@@ -28967,6 +29694,15 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
28967
29694
  mSections: mSectionSummary,
28968
29695
  offerSdp: sdpOffer
28969
29696
  } });
29697
+ if (egressPipelineKey !== null) {
29698
+ EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
29699
+ await releaseAlexaEgress({
29700
+ api,
29701
+ deviceId,
29702
+ pipelineKey: egressPipelineKey,
29703
+ logger: log
29704
+ });
29705
+ }
28970
29706
  return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
28971
29707
  }
28972
29708
  }
@@ -28978,19 +29714,32 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
28978
29714
  if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
28979
29715
  const endpoint = parsed.data.endpoint;
28980
29716
  const parsedId = parseCameraEndpointId(endpoint.endpointId);
28981
- if (parsedId.kind === "camera") try {
28982
- await deps.ctx.api.webrtcSession.closeSession.mutate({
28983
- deviceId: parsedId.deviceId,
28984
- sessionId: parsed.data.payload.sessionId
28985
- });
28986
- } catch (err) {
28987
- deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
28988
- tags: {
29717
+ if (parsedId.kind === "camera") {
29718
+ const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
29719
+ const pipelineKey = EGRESS_HANDLES.get(handleKey);
29720
+ if (pipelineKey !== void 0) {
29721
+ EGRESS_HANDLES.delete(handleKey);
29722
+ await releaseAlexaEgress({
29723
+ api: deps.ctx.api,
29724
+ deviceId: parsedId.deviceId,
29725
+ pipelineKey,
29726
+ logger: deps.logger
29727
+ });
29728
+ }
29729
+ try {
29730
+ await deps.ctx.api.webrtcSession.closeSession.mutate({
28989
29731
  deviceId: parsedId.deviceId,
28990
29732
  sessionId: parsed.data.payload.sessionId
28991
- },
28992
- meta: { error: err instanceof Error ? err.message : String(err) }
28993
- });
29733
+ });
29734
+ } catch (err) {
29735
+ deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
29736
+ tags: {
29737
+ deviceId: parsedId.deviceId,
29738
+ sessionId: parsed.data.payload.sessionId
29739
+ },
29740
+ meta: { error: err instanceof Error ? err.message : String(err) }
29741
+ });
29742
+ }
28994
29743
  }
28995
29744
  const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
28996
29745
  deps.logger.info("alexa rtc-session: SessionDisconnected", {
@@ -29928,6 +30677,89 @@ async function handleDirective(request, reply, deps) {
29928
30677
  reply.code(result.status).send(result.body);
29929
30678
  }
29930
30679
  //#endregion
30680
+ //#region src/motion-clear-hold.ts
30681
+ /**
30682
+ * Default hold. Matches `RESET_DEBOUNCE_MS` in
30683
+ * `addon-export-hap/src/mappers/builders/motion-sensor.ts` — the same problem
30684
+ * (a source that never reliably clears, or clears too eagerly) already has an
30685
+ * answer in this repo and a second number would only invite them to drift.
30686
+ */
30687
+ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
30688
+ var MotionClearHold = class {
30689
+ entries = /* @__PURE__ */ new Map();
30690
+ holdMs;
30691
+ setTimer;
30692
+ clearTimer;
30693
+ constructor(options) {
30694
+ this.holdMs = options.holdMs;
30695
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
30696
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
30697
+ }
30698
+ /**
30699
+ * The source reports motion.
30700
+ *
30701
+ * @returns `true` when Alexa must be told (it currently believes
30702
+ * NOT_DETECTED). `false` when Alexa already believes DETECTED — including
30703
+ * the case where a clear was pending, which this call cancels.
30704
+ */
30705
+ onDetected(key) {
30706
+ const entry = this.entries.get(key);
30707
+ if (entry?.state === "detected") return false;
30708
+ if (entry?.state === "holding") {
30709
+ if (entry.timer !== null) this.clearTimer(entry.timer);
30710
+ this.entries.set(key, {
30711
+ state: "detected",
30712
+ timer: null
30713
+ });
30714
+ return false;
30715
+ }
30716
+ this.entries.set(key, {
30717
+ state: "detected",
30718
+ timer: null
30719
+ });
30720
+ return true;
30721
+ }
30722
+ /**
30723
+ * The source reports no motion. Arms the hold; `release` runs once the
30724
+ * source has stayed clear for `holdMs`. A no-op when we KNOW Alexa already
30725
+ * believes NOT_DETECTED, or when a clear is already pending.
30726
+ *
30727
+ * An UNKNOWN key arms the hold rather than returning early, and that is the
30728
+ * whole reason this branch is spelled out. This map is in-memory: after an
30729
+ * addon restart it is empty, while Alexa's copy of `detectionState` is
30730
+ * whatever we last told it. If the addon respawned during a motion — which a
30731
+ * `camstack deploy` does routinely — treating "unknown" as "already clear"
30732
+ * leaves the Alexa app showing motion until that camera happens to trigger
30733
+ * again, which on a quiet camera is hours. The cost of the other choice is
30734
+ * at most one redundant NOT_DETECTED per camera per restart.
30735
+ */
30736
+ onCleared(key, release) {
30737
+ const entry = this.entries.get(key);
30738
+ if (entry !== void 0 && entry.state !== "detected") return;
30739
+ const timer = this.setTimer(() => {
30740
+ this.entries.set(key, {
30741
+ state: "idle",
30742
+ timer: null
30743
+ });
30744
+ release();
30745
+ }, this.holdMs);
30746
+ this.entries.set(key, {
30747
+ state: "holding",
30748
+ timer
30749
+ });
30750
+ }
30751
+ /** What Alexa currently believes for this key — for logs and assertions. */
30752
+ believedDetected(key) {
30753
+ const state = this.entries.get(key)?.state;
30754
+ return state === "detected" || state === "holding";
30755
+ }
30756
+ /** Cancel every pending clear and forget all state. */
30757
+ clear() {
30758
+ for (const entry of this.entries.values()) if (entry.timer !== null) this.clearTimer(entry.timer);
30759
+ this.entries.clear();
30760
+ }
30761
+ };
30762
+ //#endregion
29931
30763
  //#region src/lwa-event-gateway.ts
29932
30764
  /**
29933
30765
  * LWA Event Gateway client for Alexa proactive events.
@@ -29965,6 +30797,32 @@ var LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token";
29965
30797
  var THROTTLE_WINDOW_MS = 3e4;
29966
30798
  var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
29967
30799
  var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
30800
+ /** The interface an endpoint must advertise for a `DoorbellPress` to survive
30801
+ * the Alexa cloud. */
30802
+ var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
30803
+ /**
30804
+ * The interface an endpoint must advertise for an `ObjectDetection` event to
30805
+ * survive the Alexa cloud — and which `buildParentCameraEndpoint` does NOT
30806
+ * currently declare (the two `capabilities.push(SMART_VISION_*)` lines are
30807
+ * commented out there, deliberately).
30808
+ *
30809
+ * Measured 2026-08-07: 223 `smartvision person posted status=202` in 692 s on
30810
+ * endpoint 590 alone, every one of them against an endpoint whose discovered
30811
+ * capability list has never contained this string. The Event Gateway answers
30812
+ * 202 because the ENVELOPE parsed; the cloud then drops the event because the
30813
+ * endpoint cannot host it. This is D76 exactly — an accepted POST is not
30814
+ * evidence of delivery — and it is why the operator sees no events in the app
30815
+ * while the log reads as though they were delivered.
30816
+ */
30817
+ var SMART_VISION_INTERFACE = "Alexa.SmartVision.ObjectDetectionSensor";
30818
+ /**
30819
+ * How often the "SmartVision is not declared" drop is allowed to speak. A
30820
+ * branch that drops work must log it (`CLAUDE.md`), but at 223 drops per 11
30821
+ * minutes a per-event line is a line nobody reads — the same lesson the egress
30822
+ * teardown warn taught on 2026-08-07. First occurrence per endpoint, then every
30823
+ * hundredth, carrying the running count so the volume is never hidden.
30824
+ */
30825
+ var SMART_VISION_DROP_LOG_EVERY = 100;
29968
30826
  var LwaEventGateway = class {
29969
30827
  deps;
29970
30828
  unsubscribe = null;
@@ -29972,10 +30830,17 @@ var LwaEventGateway = class {
29972
30830
  unsubscribeBattery = null;
29973
30831
  refreshTimer = null;
29974
30832
  lastSentAt = /* @__PURE__ */ new Map();
29975
- /** Last reported motion detection state per `userId|endpointId` — only
29976
- * state TRANSITIONS are pushed to Alexa (a chatty source re-emitting
29977
- * `detected:true` every tick must not flood the Event Gateway). */
29978
- lastMotionState = /* @__PURE__ */ new Map();
30833
+ /**
30834
+ * What Alexa believes about each `userId|endpointId`'s `detectionState`,
30835
+ * and the falling-edge hold that keeps a flapping source from becoming a
30836
+ * flapping ChangeReport stream. This is the SINGLE authority on the motion
30837
+ * state we have reported — see `motion-clear-hold.ts` for the measurement
30838
+ * that made a plain transition dedupe insufficient.
30839
+ */
30840
+ motionHold = new MotionClearHold({ holdMs: DEFAULT_MOTION_CLEAR_HOLD_MS });
30841
+ /** Per-endpoint count of `ObjectDetection` events dropped because the
30842
+ * endpoint does not declare {@link SMART_VISION_INTERFACE}. */
30843
+ smartVisionDropped = /* @__PURE__ */ new Map();
29979
30844
  /** Last reported integer battery percentage per `userId|endpointId` —
29980
30845
  * battery providers may push the same level every poll cycle and
29981
30846
  * Alexa Smart Home rate-limits ChangeReports per property; dedupe
@@ -30020,15 +30885,30 @@ var LwaEventGateway = class {
30020
30885
  if (this.refreshTimer) clearInterval(this.refreshTimer);
30021
30886
  this.refreshTimer = null;
30022
30887
  this.lastSentAt.clear();
30023
- this.lastMotionState.clear();
30888
+ this.motionHold.clear();
30889
+ this.smartVisionDropped.clear();
30024
30890
  this.lastBatteryLevel.clear();
30025
30891
  }
30026
30892
  async onDoorbellPress(data) {
30027
30893
  const childDevice = await this.deps.ctx.api.deviceManager.getDevice.query({ deviceId: data.deviceId }).catch(() => null);
30028
- if (!childDevice) return;
30894
+ if (!childDevice) {
30895
+ this.deps.logger.warn("alexa lwa-gateway: doorbell press DROPPED — the pressing device is not in the registry", { tags: { deviceId: data.deviceId } });
30896
+ return;
30897
+ }
30029
30898
  const parentDeviceId = childDevice.parentDeviceId ?? data.deviceId;
30030
- if (!this.deps.listExposed().some((e) => e.deviceId === parentDeviceId)) return;
30899
+ if (!this.deps.listExposed().some((e) => e.deviceId === parentDeviceId)) {
30900
+ this.deps.logger.info("alexa lwa-gateway: doorbell press ignored — the camera is not exposed to Alexa", { tags: { deviceId: parentDeviceId } });
30901
+ return;
30902
+ }
30031
30903
  const endpointId = String(parentDeviceId);
30904
+ const signature = this.deps.getExportedSignature(parentDeviceId);
30905
+ 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`, {
30906
+ tags: { deviceId: parentDeviceId },
30907
+ meta: {
30908
+ endpointId,
30909
+ signature
30910
+ }
30911
+ });
30032
30912
  const linked = this.deps.listLinkedAccounts();
30033
30913
  if (linked.length === 0) {
30034
30914
  this.deps.logger.info("alexa lwa-gateway: doorbell press but no linked accounts — skipped", { meta: { parentDeviceId } });
@@ -30037,7 +30917,10 @@ var LwaEventGateway = class {
30037
30917
  if (!this.hasProactiveCredentials()) return;
30038
30918
  for (const account of linked) {
30039
30919
  if (!this.findToken(account.userId)) {
30040
- this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", { meta: { userId: account.userId } });
30920
+ this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", {
30921
+ tags: { deviceId: parentDeviceId },
30922
+ meta: { userId: account.userId }
30923
+ });
30041
30924
  continue;
30042
30925
  }
30043
30926
  if (this.isThrottled({
@@ -30065,12 +30948,16 @@ var LwaEventGateway = class {
30065
30948
  return false;
30066
30949
  }
30067
30950
  async postDoorbellPress(opts) {
30068
- await this.postEventBody(opts.userId, opts.endpointId, buildDoorbellPressPayload(opts), "doorbell press");
30951
+ await this.postEventBody(opts.userId, opts.endpointId, (accessToken) => buildDoorbellPressPayload({
30952
+ ...opts,
30953
+ accessToken
30954
+ }), "doorbell press");
30069
30955
  }
30070
30956
  /**
30071
- * Camera-native motion flipped. Report each detection-state TRANSITION as
30072
- * an `Alexa.MotionSensor` ChangeReport, deduped per endpoint so a chatty
30073
- * source re-emitting `detected:true` every tick doesn't flood the gateway.
30957
+ * Camera-native motion flipped. Report it as an `Alexa.MotionSensor`
30958
+ * ChangeReport: the rising edge immediately, the falling edge only once the
30959
+ * source has stayed quiet (see `MotionClearHold` for the 590 measurement
30960
+ * that made a plain transition dedupe insufficient).
30074
30961
  */
30075
30962
  async onMotionChanged(data) {
30076
30963
  if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
@@ -30081,24 +30968,90 @@ var LwaEventGateway = class {
30081
30968
  for (const account of linked) {
30082
30969
  if (!this.findToken(account.userId)) continue;
30083
30970
  const key = `${account.userId}|${endpointId}`;
30084
- if (this.lastMotionState.get(key) === data.detected) continue;
30085
- this.lastMotionState.set(key, data.detected);
30086
- await this.postEventBody(account.userId, endpointId, buildMotionChangeReportPayload({
30971
+ if (!data.detected) {
30972
+ this.motionHold.onCleared(key, () => {
30973
+ this.postMotionChange({
30974
+ userId: account.userId,
30975
+ endpointId,
30976
+ detected: false,
30977
+ timestamp: Date.now()
30978
+ }).catch((err) => {
30979
+ this.deps.logger.warn("alexa lwa-gateway: held motion clear failed to post", {
30980
+ tags: { deviceId: data.deviceId },
30981
+ meta: {
30982
+ endpointId,
30983
+ error: err instanceof Error ? err.message : String(err)
30984
+ }
30985
+ });
30986
+ });
30987
+ });
30988
+ continue;
30989
+ }
30990
+ if (!this.motionHold.onDetected(key)) continue;
30991
+ await this.postMotionChange({
30087
30992
  userId: account.userId,
30088
30993
  endpointId,
30089
- detected: data.detected,
30994
+ detected: true,
30090
30995
  timestamp: data.timestamp
30091
- }), `motion ${data.detected ? "DETECTED" : "NOT_DETECTED"}`);
30092
- if (data.detected) await this.postEventBody(account.userId, endpointId, buildSmartVisionObjectDetectionEvent({
30996
+ });
30997
+ await this.postObjectDetection({
30093
30998
  userId: account.userId,
30999
+ deviceId: data.deviceId,
30094
31000
  endpointId,
30095
- detections: [{
30096
- eventIdentifier: `${endpointId}-${data.timestamp}`,
30097
- imageNetClass: "person"
30098
- }],
30099
31001
  timestamp: data.timestamp
30100
- }), `smartvision person`);
31002
+ });
31003
+ }
31004
+ }
31005
+ async postMotionChange(opts) {
31006
+ await this.postEventBody(opts.userId, opts.endpointId, (accessToken) => buildMotionChangeReportPayload({
31007
+ ...opts,
31008
+ accessToken
31009
+ }), `motion ${opts.detected ? "DETECTED" : "NOT_DETECTED"}`);
31010
+ }
31011
+ /**
31012
+ * Pair a reported DETECTED with a SmartVision `ObjectDetection` event — the
31013
+ * event that populates the per-camera Events box in the Alexa app
31014
+ * (`Alexa.MotionSensor` alone only feeds the account-wide Activity feed).
31015
+ *
31016
+ * **Gated on the endpoint actually declaring the interface, and today that
31017
+ * gate is closed.** `buildParentCameraEndpoint` does not push
31018
+ * `SMART_VISION_OBJECT_DETECTION_SENSOR`, so every one of these events was
31019
+ * being accepted by the gateway (202) and discarded by the cloud. Posting
31020
+ * into that is not harmless: it is ~1 POST/s per flapping camera against a
31021
+ * rate-limited gateway, and it makes the log assert a delivery that never
31022
+ * happened. The doorbell path learned the same lesson today and only warns
31023
+ * — it can, because `Alexa.DoorbellEventSource` IS built for a doorbell and
31024
+ * a missing one means stale discovery, i.e. a bet worth taking. Here the
31025
+ * interface is absent by construction, so the bet is a certain loss.
31026
+ *
31027
+ * Re-opening this gate is a design item, not a toggle: see
31028
+ * `docs/design/2026-08-07-alexa-events-and-hksv-assessment.md`.
31029
+ */
31030
+ async postObjectDetection(opts) {
31031
+ const signature = this.deps.getExportedSignature(opts.deviceId);
31032
+ if (signature === null || !signature.includes(SMART_VISION_INTERFACE)) {
31033
+ const dropped = (this.smartVisionDropped.get(opts.endpointId) ?? 0) + 1;
31034
+ this.smartVisionDropped.set(opts.endpointId, dropped);
31035
+ 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`, {
31036
+ tags: { deviceId: opts.deviceId },
31037
+ meta: {
31038
+ endpointId: opts.endpointId,
31039
+ droppedSoFar: dropped,
31040
+ signature
31041
+ }
31042
+ });
31043
+ return;
30101
31044
  }
31045
+ await this.postEventBody(opts.userId, opts.endpointId, (accessToken) => buildSmartVisionObjectDetectionEvent({
31046
+ userId: opts.userId,
31047
+ endpointId: opts.endpointId,
31048
+ detections: [{
31049
+ eventIdentifier: `${opts.endpointId}-${opts.timestamp}`,
31050
+ imageNetClass: "person"
31051
+ }],
31052
+ timestamp: opts.timestamp,
31053
+ accessToken
31054
+ }), `smartvision person`);
30102
31055
  }
30103
31056
  /**
30104
31057
  * Battery slice changed. Push an `Alexa.BatteryLevelSensor`
@@ -30120,11 +31073,12 @@ var LwaEventGateway = class {
30120
31073
  const key = `${account.userId}|${endpointId}`;
30121
31074
  if (this.lastBatteryLevel.get(key) === level) continue;
30122
31075
  this.lastBatteryLevel.set(key, level);
30123
- await this.postEventBody(account.userId, endpointId, buildBatteryChangeReportPayload({
31076
+ await this.postEventBody(account.userId, endpointId, (accessToken) => buildBatteryChangeReportPayload({
30124
31077
  userId: account.userId,
30125
31078
  endpointId,
30126
31079
  batteryLevel: level,
30127
- timestamp: Date.now()
31080
+ timestamp: Date.now(),
31081
+ accessToken
30128
31082
  }), `battery ${level}%`);
30129
31083
  }
30130
31084
  }
@@ -30159,7 +31113,7 @@ var LwaEventGateway = class {
30159
31113
  let attemptedAccounts = 0;
30160
31114
  for (const account of linked) {
30161
31115
  if (!this.findToken(account.userId)) continue;
30162
- await this.postEventBody(account.userId, "discovery", buildDeleteReportPayload(account.userId, endpointIds), `discovery delete (manual purge, ${endpointIds.length})`);
31116
+ await this.postEventBody(account.userId, "discovery", (accessToken) => buildDeleteReportPayload(account.userId, endpointIds, accessToken), `discovery delete (manual purge, ${endpointIds.length})`);
30163
31117
  attemptedAccounts += 1;
30164
31118
  }
30165
31119
  return {
@@ -30168,13 +31122,21 @@ var LwaEventGateway = class {
30168
31122
  attemptedAccounts
30169
31123
  };
30170
31124
  }
30171
- /** Public delivery used by the SyncEngine. Returns true on gateway 2xx. */
30172
- async postReport(userId, body, label) {
30173
- return this.postEventBody(userId, "discovery", body, label);
31125
+ /** Public delivery used by the SyncEngine. Returns true on gateway 2xx.
31126
+ * `buildBody` is handed the access token the request will actually be sent
31127
+ * with, so the body's `scope.token` and the `Authorization` header can never
31128
+ * disagree — including after a 401 refresh. */
31129
+ async postReport(userId, buildBody, label) {
31130
+ return this.postEventBody(userId, "discovery", buildBody, label);
30174
31131
  }
30175
31132
  /** POST a proactive-event body to the region's Event Gateway, refreshing
30176
- * the LWA token once on 401. */
30177
- async postEventBody(userId, endpointId, body, label) {
31133
+ * the LWA token once on 401.
31134
+ *
31135
+ * The body is BUILT PER ATTEMPT from the token that attempt uses: the
31136
+ * Smart Home payloads carry the bearer token inside `scope.token` as well
31137
+ * as in the header, and a body built with the expired token then retried
31138
+ * with a fresh header would ship a token Amazon has already rejected. */
31139
+ async postEventBody(userId, endpointId, buildBody, label) {
30178
31140
  const url = EVENT_GATEWAY_URLS[this.deps.getRegion()];
30179
31141
  const send = async (accessToken) => fetch(url, {
30180
31142
  method: "POST",
@@ -30182,7 +31144,7 @@ var LwaEventGateway = class {
30182
31144
  "Content-Type": "application/json",
30183
31145
  Authorization: `Bearer ${accessToken}`
30184
31146
  },
30185
- body: JSON.stringify(body)
31147
+ body: JSON.stringify(buildBody(accessToken))
30186
31148
  });
30187
31149
  const initial = this.findToken(userId);
30188
31150
  if (!initial) return false;
@@ -30222,7 +31184,8 @@ var LwaEventGateway = class {
30222
31184
  }
30223
31185
  this.deps.logger.info(`alexa lwa-gateway: ${label} posted`, { meta: {
30224
31186
  userId,
30225
- endpointId
31187
+ endpointId,
31188
+ status: response.status
30226
31189
  } });
30227
31190
  return true;
30228
31191
  }
@@ -30310,7 +31273,7 @@ function buildDoorbellPressPayload(opts) {
30310
31273
  endpoint: {
30311
31274
  scope: {
30312
31275
  type: "BearerToken",
30313
- token: "replaced-by-authorization-header"
31276
+ token: opts.accessToken
30314
31277
  },
30315
31278
  endpointId: opts.endpointId
30316
31279
  },
@@ -30333,7 +31296,7 @@ function buildMotionChangeReportPayload(opts) {
30333
31296
  endpoint: {
30334
31297
  scope: {
30335
31298
  type: "BearerToken",
30336
- token: "replaced-by-authorization-header"
31299
+ token: opts.accessToken
30337
31300
  },
30338
31301
  endpointId: opts.endpointId
30339
31302
  },
@@ -30364,7 +31327,7 @@ function buildBatteryChangeReportPayload(opts) {
30364
31327
  endpoint: {
30365
31328
  scope: {
30366
31329
  type: "BearerToken",
30367
- token: "replaced-by-authorization-header"
31330
+ token: opts.accessToken
30368
31331
  },
30369
31332
  endpointId: opts.endpointId
30370
31333
  },
@@ -30394,7 +31357,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
30394
31357
  endpoint: {
30395
31358
  scope: {
30396
31359
  type: "BearerToken",
30397
- token: "replaced-by-authorization-header"
31360
+ token: opts.accessToken
30398
31361
  },
30399
31362
  endpointId: opts.endpointId
30400
31363
  },
@@ -30409,7 +31372,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
30409
31372
  })) }
30410
31373
  } };
30411
31374
  }
30412
- function buildAddOrUpdateReportPayload(userId, endpoints) {
31375
+ function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
30413
31376
  return { event: {
30414
31377
  header: {
30415
31378
  namespace: "Alexa.Discovery",
@@ -30421,12 +31384,12 @@ function buildAddOrUpdateReportPayload(userId, endpoints) {
30421
31384
  endpoints,
30422
31385
  scope: {
30423
31386
  type: "BearerToken",
30424
- token: "replaced-by-authorization-header"
31387
+ token: accessToken
30425
31388
  }
30426
31389
  }
30427
31390
  } };
30428
31391
  }
30429
- function buildDeleteReportPayload(userId, endpointIds) {
31392
+ function buildDeleteReportPayload(userId, endpointIds, accessToken) {
30430
31393
  return { event: {
30431
31394
  header: {
30432
31395
  namespace: "Alexa.Discovery",
@@ -30438,12 +31401,34 @@ function buildDeleteReportPayload(userId, endpointIds) {
30438
31401
  endpoints: endpointIds.map((endpointId) => ({ endpointId })),
30439
31402
  scope: {
30440
31403
  type: "BearerToken",
30441
- token: "replaced-by-authorization-header"
31404
+ token: accessToken
30442
31405
  }
30443
31406
  }
30444
31407
  } };
30445
31408
  }
30446
31409
  //#endregion
31410
+ //#region src/endpoint-signature.ts
31411
+ /** One capability's identity as Alexa distinguishes it. */
31412
+ function capabilityKey(capability) {
31413
+ const instance = typeof capability.instance === "string" ? capability.instance : "";
31414
+ return instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
31415
+ }
31416
+ /** Stable, order-independent signature of ONE endpoint's advertised shape. */
31417
+ function endpointKey(endpoint) {
31418
+ const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
31419
+ const categories = [...endpoint.displayCategories].sort().join(",");
31420
+ return `${endpoint.endpointId}|${categories}|${interfaces}`;
31421
+ }
31422
+ /**
31423
+ * Stable signature of the endpoint set built for ONE device. Two builds that
31424
+ * would land identically in Alexa's registry produce the same string; any
31425
+ * added, removed or renamed interface — or a changed display category —
31426
+ * produces a different one.
31427
+ */
31428
+ function endpointsSignature(endpoints) {
31429
+ return endpoints.map(endpointKey).sort().join(";");
31430
+ }
31431
+ //#endregion
30447
31432
  //#region src/sync-engine.ts
30448
31433
  var SyncEngine = class {
30449
31434
  deps;
@@ -30460,7 +31445,7 @@ var SyncEngine = class {
30460
31445
  };
30461
31446
  const endpoints = await this.deps.buildEndpoints(deviceId);
30462
31447
  if (endpoints.length === 0) {
30463
- await this.recordEndpoints(deviceId, []);
31448
+ await this.recordEndpoints(deviceId, [], null);
30464
31449
  return {
30465
31450
  deviceCount: 0,
30466
31451
  endpointCount: 0,
@@ -30471,7 +31456,7 @@ var SyncEngine = class {
30471
31456
  const endpointIds = endpoints.map((e) => e.endpointId);
30472
31457
  const failures = [];
30473
31458
  for (const account of accounts) if (!await this.deps.postAddOrUpdate(account.userId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
30474
- await this.recordEndpoints(deviceId, endpointIds);
31459
+ await this.recordEndpoints(deviceId, endpointIds, endpointsSignature(endpoints));
30475
31460
  return {
30476
31461
  deviceCount: 1,
30477
31462
  endpointCount: endpoints.length,
@@ -30517,7 +31502,7 @@ var SyncEngine = class {
30517
31502
  const endpoints = await this.deps.buildEndpoints(deviceId);
30518
31503
  if (endpoints.length === 0) continue;
30519
31504
  if (!await this.deps.postAddOrUpdate(userId, endpoints)) failures.push(`${userId}:${deviceId}`);
30520
- await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId));
31505
+ await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId), endpointsSignature(endpoints));
30521
31506
  deviceCount += 1;
30522
31507
  endpointCount += endpoints.length;
30523
31508
  }
@@ -30539,7 +31524,7 @@ var SyncEngine = class {
30539
31524
  const accounts = this.deps.listLinkedAccounts();
30540
31525
  const failures = [];
30541
31526
  for (const account of accounts) if (!await this.deps.postDelete(account.userId, ids)) failures.push(`${account.userId}:${deviceId}`);
30542
- await this.recordEndpoints(deviceId, []);
31527
+ await this.recordEndpoints(deviceId, [], null);
30543
31528
  return {
30544
31529
  deviceId,
30545
31530
  endpointCount: ids.length,
@@ -30547,11 +31532,76 @@ var SyncEngine = class {
30547
31532
  failures
30548
31533
  };
30549
31534
  }
30550
- async recordEndpoints(deviceId, ids) {
31535
+ /**
31536
+ * Re-push ONE exposed device if the endpoint shape it builds today differs
31537
+ * from the shape Alexa was last told about.
31538
+ *
31539
+ * A build that yields NO endpoints (transient registry miss) is never
31540
+ * pushed — an empty AddOrUpdateReport is an Alexa protocol error — and never
31541
+ * clears the recorded signature: forgetting it would make the next reconcile
31542
+ * re-push a shape Alexa already has.
31543
+ */
31544
+ async reconcileDevice(deviceId) {
31545
+ if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return {
31546
+ deviceId,
31547
+ pushed: false
31548
+ };
31549
+ if (this.deps.listLinkedAccounts().length === 0 || !this.deps.hasProactiveCredentials()) return {
31550
+ deviceId,
31551
+ pushed: false
31552
+ };
31553
+ const endpoints = await this.deps.buildEndpoints(deviceId);
31554
+ if (endpoints.length === 0) {
31555
+ this.deps.logger.warn("alexa sync: reconcile built ZERO endpoints — leaving Alexa untouched (transient registry miss)", { tags: { deviceId } });
31556
+ return {
31557
+ deviceId,
31558
+ pushed: false
31559
+ };
31560
+ }
31561
+ const current = endpointsSignature(endpoints);
31562
+ const last = this.deps.getExportedSignatures()[deviceId];
31563
+ if (last === current) return {
31564
+ deviceId,
31565
+ pushed: false
31566
+ };
31567
+ this.deps.logger.info("alexa sync: endpoint shape drifted — re-pushing AddOrUpdateReport so the cloud advertises the current interfaces", {
31568
+ tags: { deviceId },
31569
+ meta: {
31570
+ from: last ?? "never-recorded",
31571
+ to: current,
31572
+ interfaces: endpoints.flatMap((e) => e.capabilities.map((c) => c.interface))
31573
+ }
31574
+ });
31575
+ await this.pushDevice(deviceId);
31576
+ return {
31577
+ deviceId,
31578
+ pushed: true
31579
+ };
31580
+ }
31581
+ /** Reconcile every exposed device (boot pass + manual safety net). */
31582
+ async reconcileAllExposed() {
31583
+ const checkedDeviceIds = [];
31584
+ const pushedDeviceIds = [];
31585
+ for (const { deviceId } of this.deps.listExposed()) {
31586
+ checkedDeviceIds.push(deviceId);
31587
+ if ((await this.reconcileDevice(deviceId)).pushed) pushedDeviceIds.push(deviceId);
31588
+ }
31589
+ return {
31590
+ checkedDeviceIds,
31591
+ pushedDeviceIds
31592
+ };
31593
+ }
31594
+ /** Persist the endpoint ids AND the shape signature for a device.
31595
+ * `signature === null` forgets both (delete / empty build). */
31596
+ async recordEndpoints(deviceId, ids, signature) {
30551
31597
  const next = { ...this.deps.getExportedEndpoints() };
30552
31598
  if (ids.length > 0) next[deviceId] = ids;
30553
31599
  else delete next[deviceId];
30554
31600
  await this.deps.setExportedEndpoints(next);
31601
+ const nextSignatures = { ...this.deps.getExportedSignatures() };
31602
+ if (signature !== null) nextSignatures[deviceId] = signature;
31603
+ else delete nextSignatures[deviceId];
31604
+ await this.deps.setExportedSignatures(nextSignatures);
30555
31605
  }
30556
31606
  };
30557
31607
  //#endregion
@@ -30618,6 +31668,14 @@ var DEFAULT_DEVICE_SETTINGS = {
30618
31668
  * tokens to `/oauth/lwa-tokens`, which this addon records.
30619
31669
  * - Admin-UI wizard that walks the operator through skill creation.
30620
31670
  */
31671
+ /** Per-device debounce for the bindings-change → discovery reconcile. A
31672
+ * provider respawn emits one `DeviceBindingsChanged` per cap; reconciling in
31673
+ * the middle of that burst would read a half-registered binding set. */
31674
+ var BINDINGS_RECONCILE_DEBOUNCE_MS = 5e3;
31675
+ /** Delay before the boot-time reconcile pass. Long enough for device providers
31676
+ * to have registered their caps — a device whose bindings have not landed
31677
+ * builds a shape we must never push to Alexa. */
31678
+ var BOOT_RECONCILE_DELAY_MS = 6e4;
30621
31679
  var STREAM_PREFERENCE_OPTIONS = [
30622
31680
  {
30623
31681
  value: "auto",
@@ -30646,6 +31704,7 @@ var DEFAULT_CONFIG = {
30646
31704
  linkedAccounts: [],
30647
31705
  tokens: [],
30648
31706
  exportedEndpoints: {},
31707
+ exportedSignatures: {},
30649
31708
  publicHubUrl: "",
30650
31709
  availableEndpoints: [],
30651
31710
  ingressToken: "",
@@ -30656,6 +31715,8 @@ var SUPPORTED_KINDS = ["camera", "doorbell"];
30656
31715
  var ExportAlexaAddon = class extends BaseAddon {
30657
31716
  lwaGateway = null;
30658
31717
  syncEngine = null;
31718
+ /** Per-device debounce timers for the bindings-change discovery reconcile. */
31719
+ pendingReconcileTimers = /* @__PURE__ */ new Map();
30659
31720
  constructor() {
30660
31721
  super({ ...DEFAULT_CONFIG });
30661
31722
  }
@@ -30753,7 +31814,8 @@ var ExportAlexaAddon = class extends BaseAddon {
30753
31814
  upsertToken: (input) => this.upsertToken(input),
30754
31815
  removeLinkedAccount: (userId) => this.removeLinkedAccount(userId),
30755
31816
  getLwaCredentials: () => this.lwaCredentials(),
30756
- getRegion: () => this.config.region
31817
+ getRegion: () => this.config.region,
31818
+ getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
30757
31819
  });
30758
31820
  this.lwaGateway.start();
30759
31821
  this.syncEngine = new SyncEngine({
@@ -30762,11 +31824,15 @@ var ExportAlexaAddon = class extends BaseAddon {
30762
31824
  listLinkedAccounts: () => this.config.linkedAccounts,
30763
31825
  hasProactiveCredentials: () => this.lwaCredentials() !== null,
30764
31826
  buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
30765
- postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, buildAddOrUpdateReportPayload(userId, endpoints), `add/update (${endpoints.length})`),
30766
- postDelete: (userId, ids) => this.requireGateway().postReport(userId, buildDeleteReportPayload(userId, ids), `delete (${ids.length})`),
31827
+ postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
31828
+ postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
30767
31829
  getExportedEndpoints: () => this.config.exportedEndpoints,
30768
- setExportedEndpoints: (next) => this.updateGlobalSettings({ exportedEndpoints: next })
31830
+ setExportedEndpoints: (next) => this.updateGlobalSettings({ exportedEndpoints: next }),
31831
+ getExportedSignatures: () => this.config.exportedSignatures,
31832
+ setExportedSignatures: (next) => this.updateGlobalSettings({ exportedSignatures: next })
30769
31833
  });
31834
+ this.subscribeBindingsChangedForReconcile();
31835
+ this.scheduleBootDiscoveryReconcile();
30770
31836
  const offRemoved = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
30771
31837
  const id = event.data?.deviceId;
30772
31838
  if (typeof id !== "number") return;
@@ -30882,6 +31948,66 @@ var ExportAlexaAddon = class extends BaseAddon {
30882
31948
  }
30883
31949
  /** Manual "Re-sync devices" button — awaited so the form gets the counts.
30884
31950
  * Pushes EVERY exposed device to every linked account unconditionally. */
31951
+ /**
31952
+ * Reconcile ONE device's Alexa endpoint shape whenever its capability
31953
+ * bindings change. Debounced per device so the burst a provider respawn
31954
+ * produces (one event per cap) collapses into a single rebuild, and so the
31955
+ * reconcile reads the bindings AFTER the burst settles rather than in the
31956
+ * middle of it.
31957
+ */
31958
+ subscribeBindingsChangedForReconcile() {
31959
+ const off = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (event) => {
31960
+ const deviceId = event.data?.deviceId;
31961
+ if (typeof deviceId !== "number") return;
31962
+ if (!this.config.exposed.some((e) => e.deviceId === deviceId)) return;
31963
+ const pending = this.pendingReconcileTimers.get(deviceId);
31964
+ if (pending) clearTimeout(pending);
31965
+ const timer = setTimeout(() => {
31966
+ this.pendingReconcileTimers.delete(deviceId);
31967
+ this.reconcileDeviceDiscovery(deviceId);
31968
+ }, BINDINGS_RECONCILE_DEBOUNCE_MS);
31969
+ if (typeof timer.unref === "function") timer.unref();
31970
+ this.pendingReconcileTimers.set(deviceId, timer);
31971
+ });
31972
+ this.ctx.addDisposer(async () => {
31973
+ off();
31974
+ for (const timer of this.pendingReconcileTimers.values()) clearTimeout(timer);
31975
+ this.pendingReconcileTimers.clear();
31976
+ });
31977
+ }
31978
+ /**
31979
+ * One reconcile pass over every exposed device shortly after boot. This is
31980
+ * what heals a shape that drifted while the addon was DOWN (or under an
31981
+ * older build that had no signature at all) with no operator click — the
31982
+ * doorbell wrapper bound on 615 is exactly that case. Deliberately late
31983
+ * enough that device providers have registered their caps: a device whose
31984
+ * bindings have not landed yet builds a shape we must not push.
31985
+ */
31986
+ scheduleBootDiscoveryReconcile() {
31987
+ const timer = setTimeout(() => {
31988
+ this.requireSyncEngine().reconcileAllExposed().then((r) => {
31989
+ this.ctx.logger.info("export-alexa: boot discovery reconcile", { meta: {
31990
+ checked: r.checkedDeviceIds.length,
31991
+ pushed: r.pushedDeviceIds
31992
+ } });
31993
+ }).catch((err) => {
31994
+ this.ctx.logger.warn("export-alexa: boot discovery reconcile failed", { meta: { error: errMsg(err) } });
31995
+ });
31996
+ }, BOOT_RECONCILE_DELAY_MS);
31997
+ if (typeof timer.unref === "function") timer.unref();
31998
+ this.ctx.addDisposer(async () => clearTimeout(timer));
31999
+ }
32000
+ /** Run (and report) one device's discovery reconcile. Never throws. */
32001
+ async reconcileDeviceDiscovery(deviceId) {
32002
+ try {
32003
+ 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 } });
32004
+ } catch (err) {
32005
+ this.ctx.logger.warn("export-alexa: bindings-change discovery reconcile failed", {
32006
+ tags: { deviceId },
32007
+ meta: { error: errMsg(err) }
32008
+ });
32009
+ }
32010
+ }
30885
32011
  async resyncDiscovery() {
30886
32012
  const r = await this.requireSyncEngine().pushAllExposed();
30887
32013
  this.ctx.logger.info("export-alexa: manual discovery re-sync", { meta: { ...r } });
@@ -31435,4 +32561,4 @@ var ExportAlexaAddon = class extends BaseAddon {
31435
32561
  }
31436
32562
  };
31437
32563
  //#endregion
31438
- export { ExportAlexaAddon, ExportAlexaAddon as default, exportAlexaActions as n, AlexaRtcEgress as t };
32564
+ export { ExportAlexaAddon, ExportAlexaAddon as default, ALEXA_EGRESS_PROFILE as i, releaseAlexaEgress as n, exportAlexaActions as r, acquireAlexaEgress as t };