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