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