@camstack/types 1.2.453 → 1.2.455

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/client.js CHANGED
@@ -80,6 +80,784 @@ function buildAlawTable() {
80
80
  buildUlawTable();
81
81
  buildAlawTable();
82
82
  //#endregion
83
+ //#region src/enums/event-category.ts
84
+ var EventCategory = /* @__PURE__ */ function(EventCategory) {
85
+ EventCategory["SystemBoot"] = "system.boot";
86
+ EventCategory["SystemAddonsReady"] = "system.addons-ready";
87
+ EventCategory["SystemRestarting"] = "system.restarting";
88
+ /**
89
+ * Fired exactly once after the hub finishes booting following a
90
+ * restart that was triggered by `RestartCoordinator` (today: framework
91
+ * live-update). Payload is the marker that was on disk at boot
92
+ * (`PendingRestartMarkerPayload`); admin UI listens for it to display a
93
+ * success toast describing what changed.
94
+ *
95
+ * Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
96
+ */
97
+ EventCategory["SystemRestartCompleted"] = "system.restart-completed";
98
+ /**
99
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
100
+ * Emitted only when the material on disk actually changed, so an
101
+ * operator who trusted the old certificate by hand is told rather than
102
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
103
+ *
104
+ * Rule: docs/decisions/adr-0227-*.md
105
+ */
106
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
107
+ /**
108
+ * A newer addon or server-root package version was found by the
109
+ * authoritative registry check. Emitted once when any observed
110
+ * `latestVersion` changes (or a package/node first appears behind);
111
+ * the payload carries the full currently-available list. Repeated
112
+ * polling of the same latests is silent.
113
+ */
114
+ EventCategory["UpdateAvailable"] = "update.available";
115
+ /**
116
+ * Readiness transition for a capability provider. Every producer emits
117
+ * this event on `onInitialize` completion, `onDestroy`, and
118
+ * `$node.reconnect`; every consumer that needs to gate on a cross-process
119
+ * cap subscribes via the kernel's readiness module (`awaitReady` /
120
+ * `onReadyState`) instead of polling. Payload is
121
+ * `SystemReadyStatePayload` — see event-bus.ts.
122
+ */
123
+ EventCategory["SystemReadyState"] = "system.ready-state";
124
+ EventCategory["AddonStarted"] = "addon.started";
125
+ EventCategory["AddonStopped"] = "addon.stopped";
126
+ EventCategory["AddonRestarted"] = "addon.restarted";
127
+ EventCategory["AddonUpdated"] = "addon.updated";
128
+ EventCategory["AddonInstalled"] = "addon.installed";
129
+ EventCategory["AddonUninstalled"] = "addon.uninstalled";
130
+ EventCategory["AddonCrashed"] = "addon.crashed";
131
+ /**
132
+ * A RUNNER the D6 crash circuit-breaker gave up on — terminal.
133
+ *
134
+ * `AddonCrashed` is the routine, self-healing fact ("it crashed; it is being
135
+ * respawned"); this is the one that never resolves itself. Emitted exactly
136
+ * once per trip, by `process-service.ts`, carrying the node, the runner, the
137
+ * addons it hosted and the crash count that tripped the breaker.
138
+ *
139
+ * It exists because a runner marked terminally `failed` used to be SILENT:
140
+ * on 2026-08-18 the `recorder` runner died of three unhandled ffmpeg spawn
141
+ * errors, the breaker stopped respawning it (correctly), `/health` kept
142
+ * returning 200, and fleet-wide recording was gone for 3h15 before the
143
+ * operator's phone told him. The Notification Center's system-event intake
144
+ * consumes this category and maps it to the `addon-crash-loop` kind.
145
+ */
146
+ EventCategory["AddonRunnerFailed"] = "addon.runner-failed";
147
+ /**
148
+ * A runner whose addon refused to start with a typed reason (D877) - parked
149
+ * in `refused`, never counted by the crash breaker, retried on operator
150
+ * action. One event per refusal.
151
+ */
152
+ EventCategory["AddonRunnerRefused"] = "addon.runner-refused";
153
+ /**
154
+ * A runner settled on a state (D877): `running` (READY), `crashed`, `failed` or
155
+ * `refused`. In-process telemetry for the boot check (re-evaluate while any
156
+ * runner is still pending) and the alert centre (close a recovered runner).
157
+ */
158
+ EventCategory["ProcessStateChanged"] = "process.state-changed";
159
+ /**
160
+ * The node's one-shot runner boot check (D877): planned runners against the
161
+ * process service, `degraded` when any is failed, refused or missing. The
162
+ * alert centre turns it into one critical alert.
163
+ */
164
+ EventCategory["SystemRunnerCheck"] = "system.runner-check";
165
+ /**
166
+ * An alert EPISODE opened or closed, emitted by the alert centre for alerts
167
+ * of severity `critical` or `error` (`AlertEpisodePayload`). The alert
168
+ * centre sends no notification of its own: Notification Center turns this
169
+ * into the `alert-critical` / `alert-error` / `alert-critical-resolved`
170
+ * system-event kinds, and reconciles from the alerts store because a bus
171
+ * event may be dropped (D8, D11).
172
+ */
173
+ EventCategory["AlertEpisode"] = "alert.episode";
174
+ EventCategory["AddonError"] = "addon.error";
175
+ EventCategory["AddonPageReady"] = "addon.page-ready";
176
+ EventCategory["AddonWidgetReady"] = "addon.widget-ready";
177
+ /**
178
+ * Addon failed to load (import or initialize). Emitted by the kernel's
179
+ * AddonHealthMonitor only AFTER the boot grace period ends — failures
180
+ * during the first 5 minutes are silently retried without alerting
181
+ * (slow-starting addons must have time to come up). Post-grace, this
182
+ * event is emitted exactly once per failure-streak; AlertCenter
183
+ * consumes it to create a persistent operator-visible alert.
184
+ *
185
+ * Payload: `{ packageName, addonId?, error: { message, stack }, retryCount, nextRetryAt }`.
186
+ */
187
+ EventCategory["AddonLoadFailed"] = "addon.load-failed";
188
+ /**
189
+ * A keychain value was stored or removed (D787). Carries the ref, its
190
+ * owner and the new version — NEVER the value. A process holding the ref
191
+ * drops its cached copy and the owning `BaseAddon` re-resolves its config.
192
+ * Telemetry (D8): a process that misses it still revalidates by version.
193
+ */
194
+ EventCategory["KeychainChanged"] = "keychain.changed";
195
+ /**
196
+ * Addon recovered from a previous failure. Emitted when an addon
197
+ * transitions from `failed` back to `healthy` (typically via the
198
+ * monitor's auto-retry loop, or after manual `addons.retryLoad`).
199
+ * AlertCenter dismisses the corresponding `AddonLoadFailed` alert
200
+ * on this event.
201
+ */
202
+ EventCategory["AddonLoadRecovered"] = "addon.load-recovered";
203
+ /**
204
+ * Monitor scheduled the next retry for a failed addon. Transient —
205
+ * surfaced to the UI for live-updating the "next retry in Ns"
206
+ * countdown on the Addons page row, NOT persisted as an alert.
207
+ */
208
+ EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
209
+ /**
210
+ * A liveness invariant of this node is false — it has no devices, every
211
+ * camera is offline, or a camera that was recording has stopped producing
212
+ * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
213
+ * `findingId` is stable across ticks), only after its boot grace period.
214
+ * AlertCenter raises a persistent operator-visible alert.
215
+ *
216
+ * It exists because on 2026-08-03 the hub ran four hours with zero devices
217
+ * and zero recordings while every surface stayed quiet.
218
+ *
219
+ * Payload: `{ findingId, severity, title, message, deviceId? }`.
220
+ */
221
+ EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
222
+ /**
223
+ * A previously reported liveness fault is true again. AlertCenter dismisses
224
+ * the matching `SystemLivenessFailed` alert.
225
+ *
226
+ * Payload: `{ findingId }`.
227
+ */
228
+ EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
229
+ /**
230
+ * Monitor is attempting to reload a failed addon NOW. UI uses this
231
+ * to show a spinner during the retry attempt. Same transient nature
232
+ * as AddonRetryScheduled.
233
+ */
234
+ EventCategory["AddonRetryAttempting"] = "addon.retry-attempting";
235
+ EventCategory["DeviceRegistered"] = "device.registered";
236
+ EventCategory["DeviceUnregistered"] = "device.unregistered";
237
+ EventCategory["DeviceEnabled"] = "device.enabled";
238
+ EventCategory["DeviceDisabled"] = "device.disabled";
239
+ EventCategory["DeviceSettingsUpdated"] = "device.settings-updated";
240
+ /**
241
+ * Emitted when the set of native capability providers bound to a device
242
+ * changes — e.g. an addon registers a new native cap via
243
+ * `DeviceContext.registerNativeCap`, or all native bindings for a device
244
+ * are cleared on removal. Hub consumers re-resolve device-proxy routes
245
+ * when this fires.
246
+ */
247
+ EventCategory["DeviceBindingsChanged"] = "device.bindings-changed";
248
+ /**
249
+ * Emitted when the operator-organisational meta surface changes
250
+ * (`name` / `location` / `disabled`). Payload: `{deviceId, field,
251
+ * value}`. Live consumers (UI device list, alert center) react
252
+ * without polling. Distinct from `DeviceSettingsUpdated` which
253
+ * fires on hardware-config changes (host/port/credentials/etc).
254
+ */
255
+ EventCategory["DeviceMetaChanged"] = "device.meta-changed";
256
+ /**
257
+ * Emitted by `BaseDevice.updateSourceInfo()` after a successful patch
258
+ * to the device's upstream-system identity / rendering envelope. The
259
+ * full new SourceInfo travels in the payload so cross-process listeners
260
+ * (UI, export adapters) don't need to re-read the meta blob.
261
+ *
262
+ * Payload: `{deviceId, sourceInfo}`.
263
+ *
264
+ * Distinct from `DeviceMetaChanged` which covers the operator-edited
265
+ * surface (`name` / `location` / `disabled`). The two share the
266
+ * persistence layer (both ride on `device-manager.setMetadata` for
267
+ * SourceInfo, or `setName`/`setLocation`/`setDisabled` for the meta
268
+ * fields) but consumers care about different subsets.
269
+ */
270
+ EventCategory["DeviceSourceInfoChanged"] = "device.source-info-changed";
271
+ /**
272
+ * Emitted by DeviceStreamWiringService after a successful
273
+ * `stream-broker.registerDeviceStreams` call. Payload includes
274
+ * deviceId — consumers look up the full registered device info via
275
+ * `brokerManager.getRegisteredDevice(deviceId)`.
276
+ *
277
+ * Replaces the legacy `StreamRouterService.onDeviceRegistered` callback.
278
+ */
279
+ EventCategory["DeviceStreamsRegistered"] = "device.streams-registered";
280
+ /**
281
+ * Device-level "fully provisioned" signal — the EXPORT trigger. Emitted once
282
+ * a device's persisted export-relevant shape (its `DeviceFeature` set) is
283
+ * established or changes, carrying `{deviceId, fingerprint, generation}`.
284
+ * Export adapters (Alexa / HAP) react to THIS instead of the chatty per-cap
285
+ * `DeviceBindingsChanged`, turning a boot's incomplete→complete trickle into
286
+ * one clean delta. `fingerprint` is `canonicalDeviceFingerprint(shape)`.
287
+ *
288
+ * Spec: docs/superpowers/specs/2026-06-01-alexa-hap-export-reconciler-design.md
289
+ */
290
+ EventCategory["DeviceProvisioned"] = "device.provisioned";
291
+ /**
292
+ * Fires once when a device's initial feature-probe completes successfully
293
+ * (lastProbedAt 0→>0); exported shape is now stable. Telemetry (may be
294
+ * dropped) — consumers MUST also gate on the device record's `probed` flag.
295
+ */
296
+ EventCategory["DeviceReady"] = "device.ready";
297
+ EventCategory["IntegrationEnabled"] = "integration.enabled";
298
+ EventCategory["IntegrationDisabled"] = "integration.disabled";
299
+ EventCategory["IntegrationDeleted"] = "integration.deleted";
300
+ /** Emitted when a broker connection's status flips (connected /
301
+ * disconnected / error). Carries `{ brokerId, status, error? }`. */
302
+ EventCategory["BrokerStatusChanged"] = "broker.status-changed";
303
+ /** Emitted for every subscription-matched message routed by a
304
+ * broker provider. Carries `{ brokerId, subscriptionId, key, payload }`.
305
+ * Consumers filter by `brokerId` + `subscriptionId` in the handler. */
306
+ EventCategory["BrokerMessage"] = "broker.message";
307
+ EventCategory["ProviderStarted"] = "provider.started";
308
+ EventCategory["ProviderStopped"] = "provider.stopped";
309
+ EventCategory["ProcessCrashed"] = "process.crashed";
310
+ EventCategory["ProcessRestartScheduled"] = "process.restart_scheduled";
311
+ EventCategory["ProcessRestarted"] = "process.restarted";
312
+ /**
313
+ * The SET of storage locations changed — one was created, edited, enabled,
314
+ * disabled or deleted through `storage.upsertLocation` / `deleteLocation`.
315
+ *
316
+ * Telemetry, not a transaction (D8/D11): every consumer that re-resolves on
317
+ * it must also converge on its own periodic path, because a dropped event
318
+ * must not leave a node writing to yesterday's disk set forever. It exists
319
+ * because there was NO signal at all — an operator who added a second
320
+ * recordings disk in the admin UI got nothing, and the recorder kept its
321
+ * resolved locations until something else happened to re-resolve them
322
+ * (D387). Payload `StorageLocationsChangedPayload`.
323
+ */
324
+ EventCategory["StorageLocationsChanged"] = "storage.locations-changed";
325
+ EventCategory["RecordingStarted"] = "recording.started";
326
+ EventCategory["RecordingStopped"] = "recording.stopped";
327
+ EventCategory["RecordingError"] = "recording.error";
328
+ EventCategory["RecordingHealthDegraded"] = "recording.health.degraded";
329
+ /** Recording enabled but no segment finalized for the NC stall window. */
330
+ EventCategory["RecordingHealthStalled"] = "recording.health.stalled";
331
+ /** Recovery from {@link RecordingHealthStalled}. */
332
+ EventCategory["RecordingHealthOk"] = "recording.health.ok";
333
+ EventCategory["RecordingStorageCritical"] = "recording.storage.critical";
334
+ /** Free space on a storage location crossed the warn bar. */
335
+ EventCategory["StorageLocationLow"] = "storage.location.low";
336
+ /** Free space at/under the critical bar, or capacity probe failed (`null`). */
337
+ EventCategory["StorageLocationCritical"] = "storage.location.critical";
338
+ /** Recovery from low/critical on a storage location. */
339
+ EventCategory["StorageLocationOk"] = "storage.location.ok";
340
+ EventCategory["RecordingSegmentWritten"] = "recording.segment.written";
341
+ EventCategory["RecordingPolicyFallback"] = "recording.policy.fallback";
342
+ EventCategory["RecordingRetentionCompleted"] = "recording.retention.completed";
343
+ /** Export render progress (0–100). Telemetry (D8): a lost tick is a stale
344
+ * progress bar the client reconciles via `recordingExport.getExport`. */
345
+ EventCategory["RecordingExportProgress"] = "recording.export.progress";
346
+ EventCategory["RecordingExportCompleted"] = "recording.export.completed";
347
+ EventCategory["RecordingExportFailed"] = "recording.export.failed";
348
+ EventCategory["DetectionEvent"] = "detection.event";
349
+ EventCategory["SessionTrackNew"] = "session.track.new";
350
+ EventCategory["SessionTrackExpired"] = "session.track.expired";
351
+ EventCategory["BenchmarkProgress"] = "benchmark.progress";
352
+ EventCategory["PlatformProbePhase"] = "platform-probe.phase";
353
+ EventCategory["PipelineProgress"] = "pipeline.progress";
354
+ /** Per-frame execution trace emitted by the pipeline executor for live observability. */
355
+ EventCategory["PipelineTrace"] = "pipeline.trace";
356
+ /**
357
+ * Raw inference output emitted by `addon-pipeline-runner` after running the
358
+ * detection pipeline on a frame. Carries the `FrameResult` (with
359
+ * `detections[]`, `width`/`height`, timing debug) — never the frame
360
+ * buffer itself. Hub-side consumers (analysis pipeline, class filters,
361
+ * notifications) subscribe to this and re-emit `detection.result` after
362
+ * post-processing.
363
+ */
364
+ EventCategory["PipelineInferenceResult"] = "pipeline.inference-result";
365
+ /**
366
+ * Camera lifecycle event emitted by `addon-pipeline-orchestrator` when it
367
+ * assigns or unassigns a camera to/from an agent. Carries no frame data;
368
+ * pure observability for UI dashboards and metrics consumers.
369
+ */
370
+ EventCategory["PipelineCameraAssigned"] = "pipeline.camera-assigned";
371
+ EventCategory["PipelineCameraUnassigned"] = "pipeline.camera-unassigned";
372
+ /**
373
+ * A node the orchestrator would otherwise place cameras on has NO usable
374
+ * inference device: the operator enabled one or more accelerators there and
375
+ * the live probe reports every one of them unavailable. Emitted once per
376
+ * TRANSITION into that state (never per dispatch), and the node is dropped
377
+ * from the placement candidate set for as long as it holds.
378
+ *
379
+ * This exists because the state was previously invisible: agent-a
380
+ * absorbed 283k inference errors in a day while still being handed cameras,
381
+ * and nothing in the system said so.
382
+ *
383
+ * A node with no accelerators configured at all is NOT this — its devices
384
+ * are `disabled`, not `unavailable`, and the runner's default CPU pool
385
+ * serves it exactly as before.
386
+ */
387
+ EventCategory["PipelineNodeInferenceUnavailable"] = "pipeline.node-inference-unavailable";
388
+ /**
389
+ * A camera has an OPEN detection session and has produced no detection at
390
+ * all for longer than the blind threshold — the camera is being decoded and
391
+ * inferred and is returning nothing. Emitted once per transition into blind,
392
+ * per camera.
393
+ *
394
+ * The failure it reports: a 1h43 detection blackout on the entrance camera
395
+ * that nobody noticed, because "a camera that detects nothing" and "a quiet
396
+ * camera" produce byte-identical silence.
397
+ */
398
+ EventCategory["PipelineDetectionBlind"] = "pipeline.detection-blind";
399
+ /**
400
+ * A camera's motion source has fired constantly with no subject for an hour
401
+ * (motion duty or session count over the bar, zero detections) — it is not
402
+ * blind, its motion zones or sensitivity want tuning (D907). Paired with
403
+ * {@link PipelineMotionCalm}.
404
+ */
405
+ EventCategory["PipelineMotionNoisy"] = "pipeline.motion-noisy";
406
+ /** The recovery edge of {@link PipelineMotionNoisy}: the motion fell back, or a detection arrived. */
407
+ EventCategory["PipelineMotionCalm"] = "pipeline.motion-calm";
408
+ /**
409
+ * A camera's package LLM confirm has failed closed three times in a row
410
+ * (D931, 2026-10-26). One edge per camera until
411
+ * {@link PipelinePackageLlmAnswered}.
412
+ */
413
+ EventCategory["PipelinePackageLlmSilent"] = "pipeline.package-llm-silent";
414
+ /** The recovery edge of {@link PipelinePackageLlmSilent}: a confirm returned yes or no. */
415
+ EventCategory["PipelinePackageLlmAnswered"] = "pipeline.package-llm-answered";
416
+ /**
417
+ * Per-camera pipeline config was mutated by the orchestrator
418
+ * (a settings change via `setAgentInferenceDevices` /
419
+ * `setCameraStepToggle` / `setCameraStepSettings`). Orchestrator
420
+ * subscribes to its own emission to hot-reload the assigned runner
421
+ * via `attachCamera` so the next frame executes against the new
422
+ * engine/steps without waiting for a rebalance or the next
423
+ * `DeviceStreamsRegistered` cycle.
424
+ */
425
+ EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
426
+ /**
427
+ * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
428
+ * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
429
+ * capabilities — at boot, on agent online/offline, and on an ingest-node
430
+ * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
431
+ * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
432
+ * cross-process `getIngestOwner` query (push the authority's decision instead
433
+ * of polling it on the hot path). Idempotent state — re-emitted on every
434
+ * topology change, so a dropped event self-heals on the next one (plus the
435
+ * broker's long backstop reconcile query).
436
+ */
437
+ EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
438
+ /**
439
+ * Periodic snapshot of per-node pipeline-runner load
440
+ * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
441
+ * subscribe instead of polling `pipelineRunner.getLocalLoad`.
442
+ * `nodeId` carried in the payload + on `event.source.nodeId`.
443
+ */
444
+ EventCategory["PipelineRunnerLoadSnapshot"] = "pipeline.runner-load-snapshot";
445
+ /**
446
+ * Periodic snapshot of per-camera pipeline metrics (`CameraMetrics`
447
+ * + `deviceId` + `nodeId`). Emitted ~1Hz by every runner for each
448
+ * attached camera. UI subscribes to drive overlay phase / fps /
449
+ * inference time without polling `getCameraMetrics`.
450
+ */
451
+ EventCategory["PipelineCameraMetricsSnapshot"] = "pipeline.camera-metrics-snapshot";
452
+ /**
453
+ * Periodic snapshot of stream-broker per-broker statistics (input
454
+ * fps, decoded fps, bitrate, codec). Emitted ~1Hz by every
455
+ * stream-broker process for each active broker so the UI can drive
456
+ * the Stream / Cluster dashboards without polling
457
+ * `streamBroker.listAllProfileSlots` and friends.
458
+ */
459
+ EventCategory["StreamBrokerMetricsSnapshot"] = "stream-broker.metrics-snapshot";
460
+ /**
461
+ * Cap event fired by `stream-broker` when a profile slot enters
462
+ * "demanded" state — a cam stream has been assigned and at least one
463
+ * consumer (RTSP restream, decoded subscriber, WebRTC session, …) is
464
+ * present. Camera-provider addons (Reolink Baichuan push, …)
465
+ * subscribe to this category to start their underlying transport
466
+ * lazily. Payload: `{ deviceId, camStreamId, profile }`.
467
+ */
468
+ EventCategory["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
469
+ /**
470
+ * Cap event fired by `stream-broker` when the last consumer leaves a
471
+ * previously-demanded cam stream. Providers tear down their
472
+ * underlying transport on receipt. Payload: `{ deviceId, camStreamId }`.
473
+ */
474
+ EventCategory["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
475
+ /**
476
+ * Cap event fired by `stream-broker` when a broker fails to dial a
477
+ * managed-loopback source (today: `pull-rfc4571`) and the publisher
478
+ * needs to refresh the cached URL. The lib's TCP server idle-tears-down
479
+ * on its own schedule, so a re-publish with a fresh `host:port` is the
480
+ * only way to keep the broker dialable. Camera providers (Reolink
481
+ * Baichuan native, …) subscribe and respond by re-running their publish
482
+ * pipeline.
483
+ * Payload: `{ deviceId, camStreamId, brokerId }`.
484
+ */
485
+ EventCategory["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
486
+ /**
487
+ * A camera provider changed a device's stream parameters (codec /
488
+ * resolution / bitrate / a stream added or removed). A LOW-LATENCY NUDGE
489
+ * for the stream-broker's catalog reconcile: it carries NO authoritative
490
+ * data — the broker re-PULLS that device's `stream-catalog` cap on receipt
491
+ * (the 30s reconcile poll is the backstop if this nudge is dropped). Emitted
492
+ * by providers from `stream-params.setProfile`. Payload: `{ deviceId }`.
493
+ */
494
+ EventCategory["StreamParamsChanged"] = "stream-params.changed";
495
+ /**
496
+ * Generic per-device runtime-state change. Fired by `device-manager`
497
+ * whenever a persisted slice in any cap's `runtimeState` shape
498
+ * mutates. Payload: `{deviceId, capName, slice}`. Subscribers are
499
+ * the `deviceState` cap router (cross-process listeners) and the
500
+ * deviceProxy reactive bindings (`device.state.<capName>.value`).
501
+ * Cap-specific events (`battery.onStatusChanged`, …) still fire
502
+ * — they're authoritative for callers that want a typed payload
503
+ * without filtering on `capName`.
504
+ */
505
+ EventCategory["DeviceStateChanged"] = "device.state-changed";
506
+ /**
507
+ * The NATIVE shadow of a cap a composition has claimed fields of moved
508
+ * (D663). Payload: `{ deviceId, capName, native }` — what the provider
509
+ * wrote, BEFORE the owned fields are overlaid. Owner-only (D224): it is for
510
+ * the composer's native self-reads, and the public event routers refuse it
511
+ * (`isOwnerOnlyEventCategory`) so no consumer surface ever shows a second
512
+ * truth beside the merged `DeviceStateChanged.slice`. Emitted only under a
513
+ * `replace` claim, one per native write of that cap.
514
+ */
515
+ EventCategory["DeviceNativeShadowChanged"] = "device.native-shadow-changed";
516
+ /**
517
+ * Frame occupancy for a camera CHANGED — a tracked object was gained or
518
+ * lost. Carries `{ deviceId, totalObjects, byClass, zones }`.
519
+ *
520
+ * Emitted only on a change, so a steady scene is silent. It exists so a
521
+ * client can stop polling `zoneAnalytics.getCurrentSnapshot`: that was the
522
+ * one live badge with no push signal at all, and it cost a request every
523
+ * four seconds per visible camera.
524
+ *
525
+ * Like every event it is telemetry and may be dropped ([D8]) — a consumer
526
+ * keeps a slow reconcile rather than trusting it alone.
527
+ */
528
+ EventCategory["ZoneAnalyticsOccupancyChanged"] = "zone-analytics.occupancy-changed";
529
+ /**
530
+ * Cap event fired by every device that registers the `battery`
531
+ * capability. Mirrors the cap definition's `onStatusChanged`. Carries
532
+ * `{ deviceId, status: BatteryStatus }`. Subscribers (alert center,
533
+ * snapshot wrapper, UI) react to charge/sleep transitions without
534
+ * polling `batteryCapability.getStatus`.
535
+ */
536
+ EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
537
+ /**
538
+ * Cap event fired by every device that registers the `network-link`
539
+ * capability. Mirrors the cap definition's `onStatusChanged`. Carries
540
+ * `{ deviceId, status: NetworkLinkStatus }` — a link switch or a signal
541
+ * reading that moved.
542
+ */
543
+ EventCategory["NetworkLinkOnStatusChanged"] = "network-link.onStatusChanged";
544
+ /**
545
+ * Emitted by the battery cap provider WHEN `wakeForStream` enters the
546
+ * "wake in progress" window — between the Baichuan wake-up issue and
547
+ * the camera's first dialed-back RTP packet. The stream-broker
548
+ * manager subscribes to flip the per-broker placeholder reason to
549
+ * `'waking'` so viewers see a labelled "WAKING UP" tile instead of
550
+ * the generic `'reconnecting'` frame. Carries `{ deviceId }`. The
551
+ * complementary "wake complete" signal is the existing
552
+ * `BatteryOnStatusChanged { sleeping: false }` event.
553
+ */
554
+ EventCategory["BatteryOnWakeStarted"] = "battery.onWakeStarted";
555
+ /**
556
+ * Cap event fired by every device that registers the `doorbell`
557
+ * capability. Mirrors `doorbellCapability.events.onPressed`. Carries
558
+ * `{ deviceId, timestamp }`. Operators consuming the UI subscribe
559
+ * here to render transient ring toasts and a "Recent presses" row
560
+ * on the device detail page.
561
+ */
562
+ EventCategory["DoorbellOnPressed"] = "doorbell.onPressed";
563
+ /** Mirrors `runningStateCapability.events.onStarted`. */
564
+ EventCategory["RunningStarted"] = "running.started";
565
+ /** Mirrors `runningStateCapability.events.onFinished`. */
566
+ EventCategory["RunningFinished"] = "running.finished";
567
+ /**
568
+ * Cap event fired by every device that registers the `event-emitter`
569
+ * capability. Mirrors `eventEmitterCapability.events.onEvent`. Carries
570
+ * `{ deviceId, eventType, data, timestamp, seq }` — the device's EXACT
571
+ * declared event verbatim (NO normalization). Subscribers (UI event
572
+ * stream, advanced-notifier rules) react to fired events without
573
+ * holding a cap reference.
574
+ */
575
+ EventCategory["EventEmitted"] = "event-emitter.event";
576
+ /**
577
+ * Periodic snapshot of the per-node detection-pipeline engine
578
+ * registry (loaded engines, models resident, in-use cameras, idle
579
+ * TTL). Emitted ~0.2Hz (every 5 s) by every detection-pipeline
580
+ * process. The Engines tab subscribes to drive its inventory view
581
+ * without polling `pipelineExecutor.listLoadedEngines`.
582
+ */
583
+ EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
584
+ /**
585
+ * Per-node detection-engine runtime-provisioning transition. Emitted by
586
+ * the detection-pipeline provider on every state change of its lazy
587
+ * engine-provisioning machine (idle → installing → verifying → ready,
588
+ * or → failed with a `nextRetryAt`). Payload is the
589
+ * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
590
+ * node. The Pipeline page subscribes to drive a live "installing
591
+ * OpenVINO… / ready" indicator per node without polling
592
+ * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
593
+ * also reads the cap snapshot on mount / reconnect. Phase 2.
594
+ */
595
+ EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
596
+ /**
597
+ * Cluster topology snapshot. Carries the same payload returned by
598
+ * `nodes.topology` (every reachable node + addons + processes).
599
+ * Emitted by the hub on any agent / addon lifecycle change
600
+ * (debounced) plus a periodic safety net. Replaces UI polling on
601
+ * `nodes.topology` — admin-ui dashboards subscribe to drive the
602
+ * cluster view directly from the event payload.
603
+ */
604
+ EventCategory["ClusterTopologySnapshot"] = "cluster.topology-snapshot";
605
+ /**
606
+ * Periodic per-node system metrics snapshot (CPU / memory / GPU /
607
+ * disk / network). Emitted ~0.2 Hz by the metrics-provider addon
608
+ * for each node. Drives the dashboard SystemStatus / ProcessResources
609
+ * widgets without polling `metricsProvider.getCurrent`.
610
+ */
611
+ EventCategory["MetricsNodeResourcesSnapshot"] = "metrics.node-resources-snapshot";
612
+ /**
613
+ * Periodic per-node process-tree snapshot (camstack-related pids
614
+ * with root / managed / system classification). Emitted ~0.2 Hz by
615
+ * the metrics-provider addon. Drives the Cluster → Processes tab
616
+ * without polling `metricsProvider.listNodeProcesses`.
617
+ */
618
+ EventCategory["MetricsNodeProcessesSnapshot"] = "metrics.node-processes-snapshot";
619
+ /**
620
+ * Capability binding change event emitted by `addon-pipeline-orchestrator`
621
+ * when a user changes which addon implements a cap on a node. Subscribed
622
+ * by every kernel process to update its local `preferredProviderRegistry`
623
+ * so future capability lookups respect the new binding.
624
+ */
625
+ /**
626
+ * A capability binding was changed for a node — addon X now provides
627
+ * capability `cap` on node `nodeId`. Lives under the generic
628
+ * `capability.*` namespace because capability bindings are a kernel-
629
+ * level concept used by many addons, not strictly pipeline-scoped.
630
+ */
631
+ EventCategory["CapabilityBindingChanged"] = "capability.binding-changed";
632
+ EventCategory["ModelDownloadProgress"] = "model.download.progress";
633
+ EventCategory["AgentRegistered"] = "agent.registered";
634
+ EventCategory["AgentUnregistered"] = "agent.unregistered";
635
+ EventCategory["AgentOnline"] = "agent.online";
636
+ EventCategory["AgentOffline"] = "agent.offline";
637
+ /** Forked worker process (e.g. hub/pipeline) connected to the broker. */
638
+ EventCategory["WorkerOnline"] = "worker.online";
639
+ /** Forked worker process disconnected from the broker. */
640
+ EventCategory["WorkerOffline"] = "worker.offline";
641
+ EventCategory["AgentTaskDispatched"] = "agent.task.dispatched";
642
+ EventCategory["AgentTaskAssigned"] = "agent.task.assigned";
643
+ EventCategory["AgentTrpcConnected"] = "agent.trpc.connected";
644
+ EventCategory["AgentWsConnected"] = "agent.ws.connected";
645
+ EventCategory["AgentWsDisconnected"] = "agent.ws.disconnected";
646
+ EventCategory["AgentBackupActivated"] = "agent.backup.activated";
647
+ EventCategory["OrchestrationSettingsUpdated"] = "orchestration.settings-updated";
648
+ EventCategory["MotionAnalysis"] = "detection.motion-analysis";
649
+ /** All raw motion zones from CCL before minArea filter — for UI debug overlay. */
650
+ EventCategory["MotionZonesRaw"] = "detection.motion-zones-raw";
651
+ /**
652
+ * Per-camera motion phase transition (`watching ↔ active`) emitted
653
+ * by the runner. Mirrors the `motion.onMotionChanged` cap event
654
+ * surface — payload `MotionOnMotionChangedPayload` carries
655
+ * `{deviceId, detected, timestamp, source, regions?}`. Subscribers
656
+ * include addons that need to react to motion state without
657
+ * polling the runtime-state mirror.
658
+ */
659
+ EventCategory["MotionOnMotionChanged"] = "motion.on-motion-changed";
660
+ EventCategory["DetectionResult"] = "detection.result";
661
+ EventCategory["DetectionRaw"] = "detection.raw";
662
+ EventCategory["DetectionCameraNative"] = "detection.camera-native";
663
+ /**
664
+ * Canonical per-chunk live audio pipeline output. Payload is
665
+ * `PipelineAudioInferenceResultPayload` carrying a full `AudioResult`
666
+ * (level + detections + debug). Lives on the pipeline.* namespace
667
+ * alongside `pipeline.inference-result` (video) for symmetry.
668
+ */
669
+ EventCategory["PipelineAudioInferenceResult"] = "pipeline.audio-inference-result";
670
+ EventCategory["DetectionPhaseTransition"] = "detection.phase-transition";
671
+ /**
672
+ * A camera provider's RAW onboard motion push (D673): the `MotionOnMotionChanged`
673
+ * data shape without `triggeredBy`, built by `providerMotionEvent`. Its one
674
+ * consumer is the orchestrator's camera-sensors bridge, which re-emits the
675
+ * camera's EFFECTIVE onboard motion as `MotionOnMotionChanged` (a pass-through
676
+ * for a camera with no attached sensor). Providers must not emit
677
+ * `MotionOnMotionChanged` themselves; `scripts/check-onboard-motion-emitters.ts`
678
+ * keeps it so (camera-grid is a named, deferred exception).
679
+ */
680
+ EventCategory["ProviderMotion"] = "provider.motion";
681
+ EventCategory["ProviderDetection"] = "provider.detection";
682
+ EventCategory["EnrichmentEmbeddingStored"] = "enrichment.embedding.stored";
683
+ EventCategory["EnrichmentSceneStateChanged"] = "enrichment.scene.state-changed";
684
+ EventCategory["EnrichmentActivitySummary"] = "enrichment.activity.summary";
685
+ /**
686
+ * Unified track-lifecycle event: ONE category carrying `phase:
687
+ * 'start' | 'update' | 'end'` with a rich, typed
688
+ * `PipelineAnalyticsTrackLifecyclePayload`. Supersedes the thin
689
+ * `PipelineAnalyticsTrackStarted` / `PipelineAnalyticsTrackEnded`
690
+ * pair — a consumer subscribes once and branches on `phase`.
691
+ */
692
+ EventCategory["PipelineAnalyticsTrackLifecycle"] = "pipeline-analytics.track-lifecycle";
693
+ /**
694
+ * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
695
+ * (`phase:'start'`). Kept as a soft alias — no known subscribers.
696
+ */
697
+ EventCategory["PipelineAnalyticsTrackStarted"] = "pipeline-analytics.track-started";
698
+ /**
699
+ * @deprecated Superseded by {@link PipelineAnalyticsTrackLifecycle}
700
+ * (`phase:'end'`). Kept as a soft alias — no known subscribers.
701
+ */
702
+ EventCategory["PipelineAnalyticsTrackEnded"] = "pipeline-analytics.track-ended";
703
+ EventCategory["PipelineAnalyticsDetectionEvent"] = "pipeline-analytics.detection-event";
704
+ EventCategory["PipelineAnalyticsFrameTracked"] = "pipeline-analytics.frame-tracked";
705
+ /**
706
+ * Fired by `addon-post-analysis` when a parked (stationary) object appears
707
+ * (a track was promoted to a stationary-registry entry) or departs (the
708
+ * object moved / was removed). Telemetry (D8): lossy, drives a UI refresh of
709
+ * the dedicated "Stationary" section — never the live event feed. Payload:
710
+ * `{ deviceId, entryId, className, phase:'appeared'|'departed', timestamp, observedAt, zones }`.
711
+ */
712
+ EventCategory["PipelineAnalyticsStationaryChanged"] = "pipeline-analytics.stationary-changed";
713
+ /**
714
+ * Fired by `addon-post-analysis` when a package-drop is confirmed inside
715
+ * a package zone — a newly-appeared stationary object of a package class
716
+ * that cleared the class / zone / dwell / size gates. Payload:
717
+ * `PipelineAnalyticsPackageDeliveredPayload` carrying `{ deviceId,
718
+ * entryId, className, zoneIds, keyFrameMediaId?, bbox, timestamp }`.
719
+ * Telemetry (D8): the durable record is the `package-events` store row;
720
+ * this bus topic drives notifier rules + live UI. See
721
+ * docs/superpowers/specs/2026-07-17-package-zones-design.md §5.1.
722
+ */
723
+ EventCategory["PipelineAnalyticsPackageDelivered"] = "pipeline-analytics.package-delivered";
724
+ /**
725
+ * Fired by `addon-post-analysis` when a previously-delivered package
726
+ * leaves its zone (the stationary entry departed — moved or swept).
727
+ * Payload: `PipelineAnalyticsPackagePickedUpPayload` carrying
728
+ * `{ deviceId, entryId, deliveredEventId, className, timestamp }`.
729
+ * Telemetry (D8). See package-zones-design §5.2.
730
+ */
731
+ EventCategory["PipelineAnalyticsPackagePickedUp"] = "pipeline-analytics.package-picked-up";
732
+ /**
733
+ * Fired by `addon-post-analysis` whenever a gallery face row changes:
734
+ * `kind:'buffered'` a new detected face was persisted, `'assigned'` /
735
+ * `'unassigned'` its identity link changed, `'deleted'` the row was
736
+ * removed. Telemetry semantics (D8): a lost event only delays a refresh,
737
+ * never a correctness issue. Payload `PipelineAnalyticsFaceGalleryChangedPayload`.
738
+ */
739
+ EventCategory["PipelineAnalyticsFaceGalleryChanged"] = "pipeline-analytics.face-gallery-changed";
740
+ /**
741
+ * Fired by `addon-post-analysis` whenever a gallery plate row changes:
742
+ * `kind:'buffered'` a new read was persisted (`PlateRecognizer.onTrackEnd`),
743
+ * `'assigned'` / `'unassigned'` its vehicle link changed
744
+ * (`PlateGalleryProvider`), `'deleted'` the row was removed. Telemetry
745
+ * semantics (D8): a lost event only delays a refresh, never a correctness
746
+ * issue. Payload `PipelineAnalyticsPlateGalleryChangedPayload`.
747
+ */
748
+ EventCategory["PipelineAnalyticsPlateGalleryChanged"] = "pipeline-analytics.plate-gallery-changed";
749
+ EventCategory["CameraStreamsProfileSlotsChanged"] = "camera-streams.onProfileSlotsChanged";
750
+ /**
751
+ * Stream-broker health watchdog. Per-broker (deviceId/profile) emission.
752
+ * `stream.offline` fires after STREAM_STALE_TIMEOUT_MS without an encoded
753
+ * packet on an active broker. `stream.online` fires on first packet
754
+ * after a stale gap (or on initial first packet). Payload includes
755
+ * the assigned camStreamId as `profileKey`.
756
+ */
757
+ EventCategory["StreamOnline"] = "stream.online";
758
+ EventCategory["StreamOffline"] = "stream.offline";
759
+ EventCategory["NetworkTunnelStarted"] = "network.tunnel.started";
760
+ EventCategory["NetworkTunnelStopped"] = "network.tunnel.stopped";
761
+ /** Fired by the `local-network` cap when the host's interface set
762
+ * changes (new IP from DHCP, VPN connect, docker bridge added). */
763
+ EventCategory["LocalNetworkChanged"] = "network.local.changed";
764
+ /**
765
+ * Fired by a `mesh-network` provider (Tailscale, …) when its
766
+ * mesh-reachable host changes (join / leave / MagicDNS or 100.x IP
767
+ * change). Lets `local-network` fold the mesh endpoint into its
768
+ * connection-endpoint list without a cross-package import. Payload
769
+ * carries the preferred host (`host: ''` = no longer reachable),
770
+ * the hub port, and the scheme. Telemetry-grade (D8): consumers
771
+ * pull-reconcile from the provider's `getStatus` on reconnect. */
772
+ EventCategory["MeshNetworkChanged"] = "network.mesh.changed";
773
+ EventCategory["BackupCompleted"] = "backup.completed";
774
+ /**
775
+ * A whole backup RUN finished — every destination attempted, win or lose.
776
+ *
777
+ * `backup.completed` fires once per DESTINATION, which is the right grain for
778
+ * a progress UI and the wrong one for a notification: an operator with three
779
+ * destinations would be told three times. And a run where two of three
780
+ * destinations succeeded is not a clean success — a single "backup
781
+ * completed" that hid the failed one would be a lie, so the count of each is
782
+ * carried here and the message says both.
783
+ *
784
+ * Emitted by the backup orchestrator only after the destination loop, so a
785
+ * run that dies during the BUILD phase produces no completion at all.
786
+ */
787
+ EventCategory["BackupRunCompleted"] = "backup.run-completed";
788
+ EventCategory["BackupRestored"] = "backup.restored";
789
+ EventCategory["NotificationDispatched"] = "notification.dispatched";
790
+ EventCategory["NotificationFailed"] = "notification.failed";
791
+ /**
792
+ * A notification rule that cannot work (D905): its stored row does not load,
793
+ * or it still names a device / delivery target the prune CONFIRMED deleted.
794
+ * One edge per rule per distinct set of reasons; never on an unanswered read.
795
+ */
796
+ EventCategory["NotificationRuleBroken"] = "notification.rule-broken";
797
+ /** The clear of {@link NotificationRuleBroken}: the rule loads and names nothing deleted. */
798
+ EventCategory["NotificationRuleRepaired"] = "notification.rule-repaired";
799
+ EventCategory["DeviceUpdated"] = "device.updated";
800
+ /**
801
+ * Transport-level connectivity. Emitted by the device driver when
802
+ * the underlying control socket actually connects / disconnects
803
+ * (Baichuan TCP, ONVIF probe response, RTSP DESCRIBE, …) — NOT for
804
+ * power-state transitions on a battery camera. For battery wake /
805
+ * doze cycles see `DeviceAwake` / `DeviceSleeping`.
806
+ */
807
+ EventCategory["DeviceOnline"] = "device.online";
808
+ /**
809
+ * Stream-broker watchdog — emitted when no encoded packet has been
810
+ * received for STREAM_STALE_TIMEOUT_MS on an active broker (rtsp or
811
+ * push). Paired with DeviceOnline which fires on first packet after
812
+ * a stale gap. Payload: DeviceStreamHealthPayload.
813
+ */
814
+ EventCategory["DeviceOffline"] = "device.offline";
815
+ /**
816
+ * Battery cam woke up — physical power-state transition reported
817
+ * by the device firmware. Distinct from `DeviceOnline` so a UI panel
818
+ * watching power state doesn't flap on every UDP socket reconnect.
819
+ */
820
+ EventCategory["DeviceAwake"] = "device.awake";
821
+ /**
822
+ * Battery cam went to sleep. See `DeviceAwake`.
823
+ */
824
+ EventCategory["DeviceSleeping"] = "device.sleeping";
825
+ EventCategory["RetentionCleanup"] = "retention.cleanup";
826
+ /**
827
+ * Legacy bulk-update progress snapshot (payload `BulkUpdateState`). No longer
828
+ * emitted — F3 removed the coordinator that produced it; "Update all" now runs
829
+ * as one lifecycle engine job (`AddonsJobProgress`/`AddonsJobLog`). Retained
830
+ * (with `BulkUpdateState`) only to avoid regenerating the event maps; removed
831
+ * in F4 once live bulk progress is re-implemented over the engine events.
832
+ */
833
+ EventCategory["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
834
+ EventCategory["AddonsJobProgress"] = "addons.job-progress";
835
+ EventCategory["AddonsJobLog"] = "addons.job-log";
836
+ /**
837
+ * A container's child visibility toggled (hidden/shown). Emitted by the
838
+ * `accessories` cap when a child device is hidden or revealed.
839
+ * Payload: `{ deviceId, childDeviceId, hidden }`.
840
+ */
841
+ EventCategory["AccessoriesChildVisibilityChanged"] = "accessories.onChildVisibilityChanged";
842
+ /**
843
+ * A container's child set changed (children added/removed/reordered).
844
+ * Payload: `{ deviceId, childDeviceIds, hiddenChildIds }`.
845
+ */
846
+ EventCategory["AccessoriesChanged"] = "accessories.onAccessoriesChanged";
847
+ /**
848
+ * An action run started or ended. Emitted by the `actions` surface.
849
+ * Payload: `ActionRunChanged` (`{ actionId, runId, isRunning, outcome?, lastRunAt, lastError }`).
850
+ */
851
+ EventCategory["ActionsRunChanged"] = "actions.onRunChanged";
852
+ /**
853
+ * Progress update from a running model conversion job.
854
+ * Payload: `{ kind: 'model-convert', phase, sessionId?, pct?, detail? }`.
855
+ * Emitted by `addon-model-studio` on the converting node.
856
+ */
857
+ EventCategory["ModelConvertProgress"] = "model-convert.progress";
858
+ return EventCategory;
859
+ }({});
860
+ //#endregion
83
861
  //#region src/interfaces/addon-peer-bytes.ts
84
862
  /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
85
863
  var PeerBytesTicketSchema = zod.z.object({
@@ -963,593 +1741,6 @@ zod.z.object({
963
1741
  app: AppLoginPathSchema.nullable()
964
1742
  });
965
1743
  //#endregion
966
- //#region src/catalogs/sensor-active-state.ts
967
- /**
968
- * THE table "which boolean of this device type counts as HIGH", and the
969
- * pure evaluator of its EDGE.
970
- *
971
- * ONE table for everyone who asks "is this device HIGH?": the
972
- * recorder's `RecordingTriggers.sensorDeviceIds` trigger, the post-analysis
973
- * sensor markers, the orchestrator's camera-sensors bridge and
974
- * the sources of a pulse (`compilePulseOverride`, D936).
975
- * A second copy would diverge at the first cap added, and the symptom — "the
976
- * sensor rings the doorbell but does not record" — is exactly D62.
977
- *
978
- * ⚠ NOT `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), and the
979
- * two must not be unified: that one answers "what is the state WORD for a
980
- * rule" (and includes `presence`, `cover`, `alarm-panel`), this one "which
981
- * boolean's EDGE counts". Deliberately different vocabularies.
982
- */
983
- /**
984
- * Known binary / switch source caps → the boolean slice field whose
985
- * false→true rise counts as ACTIVE. Every entry is "fire on active".
986
- * Sensors whose "active" reading is not a plain boolean (presence's string
987
- * state, connectivity's connected flag) are deliberately excluded — a
988
- * reconnect is not a doorbell press, and it is not a recording either.
989
- */
990
- var SOURCE_CAP_ACTIVE_FIELD = {
991
- contact: "entryOpen",
992
- binary: "on",
993
- switch: "on",
994
- motion: "detected",
995
- flood: "flooded",
996
- gas: "detected",
997
- smoke: "detected",
998
- "carbon-monoxide": "detected",
999
- vibration: "detected",
1000
- tamper: "tampered"
1001
- };
1002
- //#endregion
1003
- //#region src/catalogs/coco-classmap.ts
1004
- var MACRO_LABELS = [
1005
- {
1006
- id: "person",
1007
- name: "Person"
1008
- },
1009
- {
1010
- id: "vehicle",
1011
- name: "Vehicle"
1012
- },
1013
- {
1014
- id: "animal",
1015
- name: "Animal"
1016
- },
1017
- {
1018
- id: "package",
1019
- name: "Package"
1020
- }
1021
- ];
1022
- new Set(MACRO_LABELS.map((l) => l.id));
1023
- var COCO_TO_MACRO = {
1024
- mapping: {
1025
- person: "person",
1026
- bicycle: "vehicle",
1027
- car: "vehicle",
1028
- motorcycle: "vehicle",
1029
- airplane: "vehicle",
1030
- bus: "vehicle",
1031
- train: "vehicle",
1032
- truck: "vehicle",
1033
- boat: "vehicle",
1034
- bird: "animal",
1035
- cat: "animal",
1036
- dog: "animal",
1037
- horse: "animal",
1038
- sheep: "animal",
1039
- cow: "animal",
1040
- elephant: "animal",
1041
- bear: "animal",
1042
- zebra: "animal",
1043
- giraffe: "animal",
1044
- suitcase: "package",
1045
- backpack: "package",
1046
- handbag: "package"
1047
- },
1048
- preserveOriginal: false
1049
- };
1050
- zod.z.object({
1051
- field: zod.z.string().min(1),
1052
- on: zod.z.literal("advance")
1053
- });
1054
- /**
1055
- * Output schema shared by the contribution + live methods.
1056
- *
1057
- * Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
1058
- * tabs[]) without importing from `../interfaces/config-ui.js` — a
1059
- * concrete-but-lenient Zod object keeps tRPC output inference happy
1060
- * (using `z.unknown()` here collapses unrelated router branches to
1061
- * `unknown` when the generator re-inlines the huge AppRouter type).
1062
- *
1063
- * `.passthrough()` on sections/fields accepts whatever FormBuilder
1064
- * extensions the caller adds (showWhen, displayScale, …) without
1065
- * rebuilding every time a new field kind is introduced.
1066
- */
1067
- var ContributionSectionSchema = zod.z.object({
1068
- id: zod.z.string(),
1069
- title: zod.z.string(),
1070
- description: zod.z.string().optional(),
1071
- style: zod.z.enum(["card", "accordion"]).optional(),
1072
- defaultCollapsed: zod.z.boolean().optional(),
1073
- columns: zod.z.union([
1074
- zod.z.literal(1),
1075
- zod.z.literal(2),
1076
- zod.z.literal(3),
1077
- zod.z.literal(4)
1078
- ]).optional(),
1079
- tab: zod.z.string().optional(),
1080
- location: zod.z.enum([
1081
- "settings",
1082
- "top-tab",
1083
- "hero"
1084
- ]).optional(),
1085
- order: zod.z.number().optional(),
1086
- fields: zod.z.array(zod.z.any())
1087
- });
1088
- var ContributionTabSchema = zod.z.object({
1089
- id: zod.z.string(),
1090
- label: zod.z.string(),
1091
- icon: zod.z.string(),
1092
- order: zod.z.number().optional()
1093
- });
1094
- zod.z.object({
1095
- tabs: zod.z.array(ContributionTabSchema).optional(),
1096
- sections: zod.z.array(ContributionSectionSchema)
1097
- }).nullable();
1098
- zod.z.object({ deviceId: zod.z.number() }), zod.z.object({ deviceId: zod.z.number() }), zod.z.object({
1099
- deviceId: zod.z.number(),
1100
- patch: zod.z.record(zod.z.string(), zod.z.unknown())
1101
- }), zod.z.object({ success: zod.z.literal(true) });
1102
- zod.z.object({ deviceId: zod.z.number() }), zod.z.unknown().nullable();
1103
- function method(input, output, options) {
1104
- return {
1105
- input,
1106
- output,
1107
- kind: options?.kind ?? "query",
1108
- ...options?.auth !== void 0 ? { auth: options.auth } : {},
1109
- ...options?.access !== void 0 ? { access: options.access } : {},
1110
- ...options?.role !== void 0 ? { role: options.role } : {},
1111
- ...options?.caller !== void 0 ? { caller: options.caller } : {},
1112
- timeoutMs: options?.timeoutMs
1113
- };
1114
- }
1115
- /**
1116
- * A method a SOURCE of a collection cap may legitimately not serve — OPTIONAL
1117
- * on `InferProvider`. The `providerOptional: true` literal is what
1118
- * `InferProvider` keys on; see {@link CapabilityMethodSchema.providerOptional}
1119
- * for when this is the honest answer and when it is a soft stub.
1120
- */
1121
- function optionalMethod(input, output, options) {
1122
- return {
1123
- ...method(input, output, options),
1124
- providerOptional: true
1125
- };
1126
- }
1127
- //#endregion
1128
- //#region src/capabilities/notification-output.cap.ts
1129
- /**
1130
- * notification-output — canonical, capability-gated notification delivery.
1131
- *
1132
- * Apprise-derived model (see
1133
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
1134
- * callers emit ONE canonical `Notification`; each provider declares a
1135
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
1136
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
1137
- * message to what the kind supports — callers never special-case a service.
1138
- *
1139
- * DESIGN DECISIONS (locked):
1140
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
1141
- * `setTargetEnabled`), each provider persisting via the `settings-store`
1142
- * cap. Rationale: the admin UI needs one uniform surface across the
1143
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
1144
- * alternative would fork the UI per addon and cannot host the
1145
- * discovery→adopt flow.
1146
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
1147
- * the generated cap-mount auto-`concatCollection`-fans them across every
1148
- * registered provider (notifiers addon + HA addon) so one catalog is
1149
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
1150
- * `addonId` the generated collection router extracts from the call input.
1151
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
1152
- * transformer) + UDS MsgPack both round-trip typed arrays — already used by
1153
- * `storage` / `storage-provider` / `recording` caps over the same path. No
1154
- * base64 fallback needed.
1155
- *
1156
- * TODO (deferred, closed-set change — separate decision): add
1157
- * `providerKind: 'notify'` so notification providers surface on the unified
1158
- * admin "Integrations" page.
1159
- */
1160
- /**
1161
- * Zentik-derived typed-media enum — the superset across every kind. Each
1162
- * adapter picks what it supports and the degrade engine filters the rest.
1163
- */
1164
- var AttachmentMediaTypeSchema = zod.z.enum([
1165
- "image",
1166
- "video",
1167
- "gif",
1168
- "audio",
1169
- "icon"
1170
- ]);
1171
- /**
1172
- * A single attachment. Exactly one of `url` (remote source, most adapters
1173
- * prefer this) or `bytes` (inline source; required for Pushover-style
1174
- * bytes-only kinds) MUST be present — the degrade engine expresses a
1175
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
1176
- */
1177
- var AttachmentSchema = zod.z.object({
1178
- mediaType: AttachmentMediaTypeSchema,
1179
- url: zod.z.string().optional(),
1180
- bytes: zod.z.instanceof(Uint8Array).optional(),
1181
- mime: zod.z.string().optional(),
1182
- name: zod.z.string().optional()
1183
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
1184
- var NotificationFormatSchema = zod.z.enum([
1185
- "text",
1186
- "markdown",
1187
- "html"
1188
- ]);
1189
- /**
1190
- * The CLOSED icon vocabulary an action button may use.
1191
- *
1192
- * A closed set, not a free string, and that is the whole point: an arbitrary
1193
- * icon name is one that ntfy renders, zentik silently drops, and nobody
1194
- * notices — the same class of gap as a zone vocabulary nothing produced
1195
- * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
1196
- * declares `actionIcons: false` and the degrade engine strips the field.
1197
- *
1198
- * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
1199
- * renderer's icon set; "acknowledge" survives an adapter that draws it
1200
- * differently.
1201
- *
1202
- * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
1203
- *
1204
- * These names are for US. **No adapter may forward one verbatim.** Each maps
1205
- * the whole set onto its own renderer's vocabulary through a
1206
- * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
1207
- * fallback, so adding a member here fails every adapter's build until someone
1208
- * decides its glyph, which is the only place that decision can be made
1209
- * honestly.
1210
- *
1211
- * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
1212
- * `disarm` straight through; iOS feeds that string to
1213
- * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
1214
- * and every snooze and alarm button arrived BLANK. A pass-through is not a
1215
- * mapping, and "the field is documented" is not "the value renders".
1216
- *
1217
- * Adding a member is TRAIN-BOUND. The enum lives in the published
1218
- * `@camstack/server` closure and the cap seam validates against the HUB's copy,
1219
- * so an addon that emits a token the running hub does not know does not lose an
1220
- * icon — its whole `send` fails Zod validation and the notification never
1221
- * arrives. Never emit a new token from an addon before the train carrying it.
1222
- */
1223
- var NotificationActionIconSchema = zod.z.enum([
1224
- "acknowledge",
1225
- "dismiss",
1226
- "silence",
1227
- "snooze",
1228
- "view",
1229
- "play",
1230
- "open",
1231
- "close",
1232
- "lock",
1233
- "unlock",
1234
- "arm",
1235
- "arm-home",
1236
- "arm-away",
1237
- "arm-night",
1238
- "disarm",
1239
- "light",
1240
- "alert",
1241
- "camera"
1242
- ]);
1243
- /** A single tap-through action button. */
1244
- var NotificationActionSchema = zod.z.object({
1245
- id: zod.z.string(),
1246
- label: zod.z.string(),
1247
- url: zod.z.string().optional(),
1248
- /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
1249
- icon: NotificationActionIconSchema.optional(),
1250
- /**
1251
- * Renders in a warning style where the notifier supports it.
1252
- *
1253
- * A HINT, never a gate. The callback's authority is its token and nothing
1254
- * else — see `notification-center/action-token.ts` for what that does and
1255
- * does not buy.
1256
- */
1257
- destructive: zod.z.boolean().optional(),
1258
- /**
1259
- * How the tap should REACH the url.
1260
- *
1261
- * `navigate` (absent, and every button authored before this field) opens it:
1262
- * the phone leaves the notification and shows whatever the callback returns.
1263
- * That is right for a button whose answer the operator wants to read.
1264
- *
1265
- * `background` fires it as a POST and stays put. It exists for the buttons
1266
- * whose whole point is not to interrupt — "silence this for 30 minutes" is
1267
- * an answer to the notification, and being thrown into a browser tab to
1268
- * confirm it costs more attention than the notification did. A backend that
1269
- * cannot do a background call renders it as an ordinary link (the adapters
1270
- * fall back rather than dropping the button), so this is a preference, never
1271
- * a requirement.
1272
- */
1273
- mode: zod.z.enum(["navigate", "background"]).optional()
1274
- });
1275
- /** A delivery parameter value: what a notifier declared it takes. */
1276
- var DeliveryParamValueSchema = zod.z.union([
1277
- zod.z.string(),
1278
- zod.z.number(),
1279
- zod.z.boolean()
1280
- ]);
1281
- /**
1282
- * One extra delivery parameter a notifier KIND declares (D938). A rule that
1283
- * addresses a target of that kind may set it; a kind that declares nothing is
1284
- * never handed one. The admin UI derives its control from this descriptor —
1285
- * no editor knows a service by name.
1286
- *
1287
- * Optional fields are `.optional()`, never `.default()` (a Zod default does not
1288
- * run on the addon cap path).
1289
- */
1290
- var DeliveryParamSchema = zod.z.object({
1291
- key: zod.z.string().min(1),
1292
- label: zod.z.string(),
1293
- /** i18n key of the label, resolved by the declaring addon's namespace. */
1294
- labelKey: zod.z.string().optional(),
1295
- type: zod.z.enum([
1296
- "enum",
1297
- "number",
1298
- "boolean",
1299
- "string"
1300
- ]),
1301
- /** `enum` only: the closed set of accepted values. */
1302
- options: zod.z.array(zod.z.object({
1303
- value: zod.z.string(),
1304
- label: zod.z.string()
1305
- })).optional(),
1306
- /** `number` only. */
1307
- min: zod.z.number().optional(),
1308
- max: zod.z.number().optional(),
1309
- /** What the notifier does when the rule sets nothing; shown as the placeholder. */
1310
- default: DeliveryParamValueSchema.optional()
1311
- });
1312
- /**
1313
- * The canonical notification. `body` is the only hard field (Apprise model).
1314
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
1315
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
1316
- * the adapter maps this ordinal onto its native level. `level?` is an
1317
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
1318
- * `priority` for that one target.
1319
- */
1320
- var NotificationSchema = zod.z.object({
1321
- body: zod.z.string(),
1322
- title: zod.z.string().optional(),
1323
- format: NotificationFormatSchema.default("text"),
1324
- priority: zod.z.number().int().min(1).max(5).default(3),
1325
- level: zod.z.string().optional(),
1326
- attachments: zod.z.array(AttachmentSchema).optional(),
1327
- clickUrl: zod.z.string().optional(),
1328
- actions: zod.z.array(NotificationActionSchema).optional(),
1329
- sound: zod.z.string().optional(),
1330
- ttl: zod.z.number().optional(),
1331
- tag: zod.z.string().optional(),
1332
- deviceId: zod.z.number().optional(),
1333
- eventId: zod.z.string().optional(),
1334
- metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
1335
- /**
1336
- * Values for the parameters the target's kind DECLARES in
1337
- * {@link TargetKindSchema} `deliveryParams`. Already validated against the
1338
- * declaration by the sender; a notifier reads only the keys it declared.
1339
- */
1340
- delivery: zod.z.record(zod.z.string(), DeliveryParamValueSchema).optional()
1341
- });
1342
- /** One declared native severity/priority level for a kind. */
1343
- var TargetKindLevelSchema = zod.z.object({
1344
- id: zod.z.string(),
1345
- label: zod.z.string(),
1346
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
1347
- ordinal: zod.z.number().int().min(1).max(5).nullable(),
1348
- flags: zod.z.object({
1349
- critical: zod.z.boolean().optional(),
1350
- silent: zod.z.boolean().optional(),
1351
- noPush: zod.z.boolean().optional()
1352
- }).optional(),
1353
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
1354
- requires: zod.z.array(zod.z.string()).optional(),
1355
- description: zod.z.string().optional()
1356
- });
1357
- /** Attachment capabilities for a kind (drives the degrade engine + test panel). */
1358
- var TargetKindAttachmentsCapsSchema = zod.z.object({
1359
- mediaTypes: zod.z.array(AttachmentMediaTypeSchema),
1360
- mode: zod.z.enum([
1361
- "url",
1362
- "bytes",
1363
- "both"
1364
- ]),
1365
- max: zod.z.number().int().nonnegative(),
1366
- maxBytes: zod.z.number().int().positive().optional()
1367
- });
1368
- /** The full capability block consulted before dispatch. */
1369
- var TargetKindCapsSchema = zod.z.object({
1370
- attachments: TargetKindAttachmentsCapsSchema,
1371
- /** Max action buttons (0 = none). */
1372
- actions: zod.z.number().int().nonnegative(),
1373
- /**
1374
- * Whether this kind renders a per-action ICON.
1375
- *
1376
- * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
1377
- * run on the addon cap path — three production failures in one day taught
1378
- * this repo that once. Absent is read as false by the degrade engine, which
1379
- * is the safe direction: an icon that is not rendered costs nothing, an icon
1380
- * assumed and dropped costs the operator's trust in the field.
1381
- */
1382
- actionIcons: zod.z.boolean().optional(),
1383
- levels: zod.z.array(TargetKindLevelSchema),
1384
- format: zod.z.array(NotificationFormatSchema),
1385
- clickUrl: zod.z.boolean(),
1386
- sound: zod.z.boolean(),
1387
- ttl: zod.z.boolean(),
1388
- bodyMaxLen: zod.z.number().int().positive()
1389
- });
1390
- /**
1391
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
1392
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
1393
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
1394
- * the union is large and not meant for runtime validation here; the exported
1395
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
1396
- */
1397
- var ConfigSchemaPassthrough = zod.z.unknown();
1398
- var TargetKindSchema = zod.z.object({
1399
- kind: zod.z.string(),
1400
- label: zod.z.string(),
1401
- icon: zod.z.string(),
1402
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
1403
- addonId: zod.z.string(),
1404
- /**
1405
- * URL of the kind's bundled BRAND icon, served by the providing addon over
1406
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
1407
- * when the addon bundles no icon for that kind — the client then falls back
1408
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
1409
- *
1410
- * Root-relative on purpose: it resolves against whatever origin serves a web
1411
- * client, and a native client joins it onto its own hub base.
1412
- *
1413
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
1414
- * field that survived only because the runtime cap-router forwards provider
1415
- * output verbatim — so every consumer had to re-declare it by hand to stop
1416
- * its own Zod parse from stripping it, and the whole arrangement would have
1417
- * broken silently the moment output validation was tightened anywhere.
1418
- */
1419
- iconUrl: zod.z.string().optional(),
1420
- /**
1421
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
1422
- *
1423
- * The server knows this and therefore says it, because the client cannot
1424
- * safely guess: a React-Native client renders SVG and raster through two
1425
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
1426
- * not decode SVG on iOS/Android), so without this it silently fell back to a
1427
- * placeholder glyph for every vector icon while the web build looked fine.
1428
- *
1429
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
1430
- * not been updated — a client that cannot determine the type should prefer
1431
- * its raster path, which is the safe default for an unknown image.
1432
- */
1433
- iconMediaType: zod.z.string().optional(),
1434
- configSchema: ConfigSchemaPassthrough,
1435
- /**
1436
- * The config keys that decide WHERE this kind's secrets are sent (server,
1437
- * base URL, instance, chat, recipient). A save that changes any of them
1438
- * while leaving a secret redacted is refused by the provider (D634), so an
1439
- * editor uses this to ask for the secret on exactly those edits instead of
1440
- * on every edit.
1441
- *
1442
- * Optional: absent from a provider that holds no secret (the Home Assistant
1443
- * kinds) and from a provider older than this field — an editor that finds
1444
- * it absent must assume ANY edit may need the secret.
1445
- */
1446
- destinationKeys: zod.z.array(zod.z.string()).optional(),
1447
- supportsDiscovery: zod.z.boolean(),
1448
- /**
1449
- * Extra delivery parameters a rule may set for a target of this kind (D938).
1450
- * Absent = none; the rule editor then offers nothing.
1451
- */
1452
- deliveryParams: zod.z.array(DeliveryParamSchema).optional(),
1453
- caps: TargetKindCapsSchema
1454
- });
1455
- /**
1456
- * A persisted target. `config` holds secrets; providers REDACT secret fields
1457
- * (return a presence marker only) when serving `listTargets` — never
1458
- * round-trip a stored secret to the UI.
1459
- */
1460
- var TargetSchema = zod.z.object({
1461
- id: zod.z.string(),
1462
- name: zod.z.string(),
1463
- kind: zod.z.string(),
1464
- addonId: zod.z.string(),
1465
- enabled: zod.z.boolean(),
1466
- config: zod.z.record(zod.z.string(), zod.z.unknown())
1467
- });
1468
- /** A discovery-surfaced candidate (config is partial + non-secret). */
1469
- var DiscoveredTargetSchema = zod.z.object({
1470
- kind: zod.z.string(),
1471
- suggestedName: zod.z.string(),
1472
- config: zod.z.record(zod.z.string(), zod.z.unknown())
1473
- });
1474
- /** The degrade engine's report — what was resolved / dropped / degraded. */
1475
- var RenderedAsSchema = zod.z.object({
1476
- level: zod.z.string(),
1477
- format: NotificationFormatSchema,
1478
- attachmentsSent: zod.z.number().int().nonnegative(),
1479
- actionsSent: zod.z.number().int().nonnegative(),
1480
- truncated: zod.z.boolean(),
1481
- dropped: zod.z.array(zod.z.string())
1482
- });
1483
- var SendResultSchema = zod.z.object({
1484
- success: zod.z.boolean(),
1485
- error: zod.z.string().optional(),
1486
- renderedAs: RenderedAsSchema.optional()
1487
- });
1488
- /** Same shape as SendResult — kept as a distinct name for the test panel. */
1489
- var TestResultSchema = SendResultSchema;
1490
- /**
1491
- * Pre-save credential probe against a partial (unsaved) target config.
1492
- * `resolvedUrl` never includes secrets (token stripped from path).
1493
- */
1494
- var ProbeTargetConfigResultSchema = zod.z.object({
1495
- ok: zod.z.boolean(),
1496
- resolvedUrl: zod.z.string(),
1497
- httpStatus: zod.z.number().int().optional(),
1498
- stateInstance: zod.z.string().optional(),
1499
- error: zod.z.string().optional()
1500
- });
1501
- method(zod.z.object({}), zod.z.array(TargetKindSchema)), method(zod.z.object({}), zod.z.array(TargetSchema), { auth: "admin" }), method(zod.z.object({ targetId: zod.z.string() }), TargetSchema, { auth: "admin" }), method(zod.z.object({
1502
- kind: zod.z.string(),
1503
- config: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
1504
- /**
1505
- * When editing / duplicating, the blank password field never round-trips
1506
- * the stored token. Pass the source target id so the provider can restore
1507
- * secrets server-side before dialling (same merge as upsert).
1508
- */
1509
- targetId: zod.z.string().optional()
1510
- }), zod.z.array(DiscoveredTargetSchema), { auth: "admin" }), method(zod.z.object({
1511
- kind: zod.z.string(),
1512
- config: zod.z.record(zod.z.string(), zod.z.unknown()),
1513
- /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
1514
- targetId: zod.z.string().optional()
1515
- }), ProbeTargetConfigResultSchema, {
1516
- kind: "mutation",
1517
- auth: "admin"
1518
- }), method(zod.z.object({
1519
- targetId: zod.z.string(),
1520
- notification: NotificationSchema
1521
- }), SendResultSchema, {
1522
- kind: "mutation",
1523
- auth: "admin"
1524
- }), method(zod.z.object({
1525
- targetId: zod.z.string(),
1526
- sample: NotificationSchema.optional()
1527
- }), TestResultSchema, {
1528
- kind: "mutation",
1529
- auth: "admin"
1530
- }), method(zod.z.object({
1531
- target: TargetSchema,
1532
- /**
1533
- * Duplicate flow: create a NEW target id while copying secrets from an
1534
- * existing row. The UI never holds the stored token; blank / redacted
1535
- * password fields are restored from this source (D634 still applies if
1536
- * destinations diverge without a re-typed secret).
1537
- */
1538
- cloneSecretsFrom: zod.z.string().optional()
1539
- }), TargetSchema, {
1540
- kind: "mutation",
1541
- auth: "admin"
1542
- }), method(zod.z.object({ targetId: zod.z.string() }), zod.z.void(), {
1543
- kind: "mutation",
1544
- auth: "admin"
1545
- }), method(zod.z.object({
1546
- targetId: zod.z.string(),
1547
- enabled: zod.z.boolean()
1548
- }), zod.z.void(), {
1549
- kind: "mutation",
1550
- auth: "admin"
1551
- });
1552
- //#endregion
1553
1744
  //#region src/device/icon-catalog.generated.ts
1554
1745
  /** Lucide category ids, alphabetical. `ICON_META` indexes into this. */
1555
1746
  var ICON_CATEGORY_IDS = [
@@ -21371,242 +21562,869 @@ var ICON_SYNONYMS = {
21371
21562
  ]
21372
21563
  };
21373
21564
  /**
21374
- * Spellings an operator may already have PERSISTED that no lucide package
21375
- * accepts any more, and the catalog key they draw. They resolve
21376
- * (`canonicalIconKey`) but are never offered. `gauge2` was this repo's own
21377
- * pre-catalog spelling of `gauge`.
21565
+ * Spellings an operator may already have PERSISTED that no lucide package
21566
+ * accepts any more, and the catalog key they draw. They resolve
21567
+ * (`canonicalIconKey`) but are never offered. `gauge2` was this repo's own
21568
+ * pre-catalog spelling of `gauge`.
21569
+ */
21570
+ var LEGACY_ICON_ALIASES = { gauge2: "gauge" };
21571
+ //#endregion
21572
+ //#region src/device/icon-catalog.ts
21573
+ /**
21574
+ * icon-catalog — THE icon vocabulary (D854).
21575
+ *
21576
+ * Every place that picks or stores an icon — a device's display override, a
21577
+ * location, a site zone, a viewer accessory, an `icon` settings field — names
21578
+ * it by a key of this catalog and nothing else. There is no curated subset and
21579
+ * no second list: the catalog is every lucide icon that both surfaces can draw
21580
+ * (the admin UI's `lucide-react` and the viewer's `lucide-react-native`), the
21581
+ * intersection computed by `scripts/generate-icon-catalog.ts` into the
21582
+ * committed `icon-catalog.generated.ts`.
21583
+ *
21584
+ * Like the vocabularies it replaces, this module holds STRINGS only — no React,
21585
+ * no lucide import, no glyph. Each surface owns its glyph table (the web one
21586
+ * lives in `ui-library`, loaded lazily), and a test on each side pins that it
21587
+ * covers {@link ICON_KEYS}.
21588
+ *
21589
+ * ## What is persisted
21590
+ *
21591
+ * The canonical kebab-case lucide id ({@link IconKey}). Spellings that a lucide
21592
+ * package still accepts (`home` for `house`) and this repo's own legacy ones
21593
+ * (`gauge2`) RESOLVE ({@link canonicalIconKey}) so a stored value never goes
21594
+ * stale, but they are never OFFERED by search.
21595
+ *
21596
+ * ## Search
21597
+ *
21598
+ * Over the key, the Italian/English overlay in `icon-synonyms.ts`, any words
21599
+ * the caller adds, and lucide's English tags — in that order of weight.
21600
+ */
21601
+ var KEY_SET$1 = new Set(ICON_KEYS);
21602
+ var ALIAS_TO_KEY = new Map([...Object.entries(ICON_ALIASES), ...Object.entries(LEGACY_ICON_ALIASES)]);
21603
+ /** Is this a canonical key of the catalog? (An alias is not — see {@link canonicalIconKey}.) */
21604
+ function isIconKey(value) {
21605
+ return typeof value === "string" && KEY_SET$1.has(value);
21606
+ }
21607
+ /**
21608
+ * A stored value as a catalog key: the key itself, the key a still-accepted or
21609
+ * legacy spelling stands for, or `undefined` — never a guess.
21610
+ */
21611
+ function canonicalIconKey(value) {
21612
+ if (typeof value !== "string") return void 0;
21613
+ if (isIconKey(value)) return value;
21614
+ return ALIAS_TO_KEY.get(value);
21615
+ }
21616
+ /**
21617
+ * A stored icon value as a catalog key: its canonical key when it is (or
21618
+ * aliases) one, `fallback` otherwise. For an `icon` settings field whose stored
21619
+ * value may predate the catalog (a free-text lucide name) — the field's default
21620
+ * is drawn rather than nothing.
21621
+ */
21622
+ function resolveIconKey(value, fallback) {
21623
+ return canonicalIconKey(value) ?? fallback;
21624
+ }
21625
+ /**
21626
+ * Wire/storage schema for one icon key.
21627
+ *
21628
+ * Deliberately NOT a `z.enum` of the catalog: ~1,700 literals would be inlined
21629
+ * into every cap router, type and API-reference row that carries an icon. The
21630
+ * check is membership in the catalog and the type is {@link IconKey}.
21631
+ */
21632
+ var IconKeySchema = zod.z.custom(isIconKey, "Not an icon of the catalog");
21633
+ var ENTRIES = ICON_KEYS.map((key, index) => {
21634
+ const [categoryIndexes, tags] = ICON_META[index] ?? [[], []];
21635
+ const categories = categoryIndexes.flatMap((i) => {
21636
+ const id = ICON_CATEGORY_IDS[i];
21637
+ return id === void 0 ? [] : [id];
21638
+ });
21639
+ return {
21640
+ key,
21641
+ categories,
21642
+ primaryCategory: categories[0] ?? ICON_CATEGORY_IDS[0],
21643
+ tags,
21644
+ synonyms: ICON_SYNONYMS[key] ?? []
21645
+ };
21646
+ });
21647
+ var ENTRY_BY_KEY = new Map(ENTRIES.map((e) => [e.key, e]));
21648
+ /** The entry for a canonical key, or `undefined`. */
21649
+ function iconEntry(key) {
21650
+ return ENTRY_BY_KEY.get(key);
21651
+ }
21652
+ /** Every entry, in key order. */
21653
+ var ICON_ENTRIES = ENTRIES;
21654
+ /** Categories a picker offers first: the ones this product's icons mostly live in. */
21655
+ var LEADING_CATEGORIES = [
21656
+ "devices",
21657
+ "home",
21658
+ "security",
21659
+ "buildings",
21660
+ "connectivity",
21661
+ "multimedia",
21662
+ "nature",
21663
+ "weather",
21664
+ "transportation",
21665
+ "animals"
21666
+ ];
21667
+ var PRIMARY_CATEGORIES = new Set(ENTRIES.map((e) => e.primaryCategory));
21668
+ /**
21669
+ * The categories a picker files icons under, in picker order: the leading ones,
21670
+ * then the rest alphabetically. Only categories that are some icon's PRIMARY
21671
+ * one — a category lucide lists only as a secondary has no section of its own.
21672
+ */
21673
+ var ICON_CATEGORIES = [...LEADING_CATEGORIES, ...ICON_CATEGORY_IDS.filter((c) => !LEADING_CATEGORIES.includes(c))].filter((c) => PRIMARY_CATEGORIES.has(c));
21674
+ /** The icons filed under a category as their PRIMARY one, in key order. */
21675
+ function iconsInCategory(category) {
21676
+ return ENTRIES.filter((e) => e.primaryCategory === category);
21677
+ }
21678
+ function normalise(text) {
21679
+ return text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase().replace(/[_-]+/g, " ").trim();
21680
+ }
21681
+ /** Does `needle` appear in `haystack` in order, though not adjacently? */
21682
+ function isSubsequence(needle, haystack) {
21683
+ let i = 0;
21684
+ for (const ch of haystack) {
21685
+ if (ch === needle[i]) i += 1;
21686
+ if (i === needle.length) return true;
21687
+ }
21688
+ return needle.length === 0;
21689
+ }
21690
+ /**
21691
+ * How well one word answers the query. 0 = not at all. An exact word beats a
21692
+ * prefix, a prefix beats a word STARTING inside the phrase, and a loose
21693
+ * subsequence comes last because it matches almost anything.
21694
+ */
21695
+ function scoreWord(query, word, loose) {
21696
+ if (word === query) return 100;
21697
+ if (word.startsWith(query)) return 80;
21698
+ if (word.split(" ").some((w) => w.startsWith(query))) return 60;
21699
+ if (word.includes(query)) return 40;
21700
+ if (loose && isSubsequence(query, word)) return 15;
21701
+ return 0;
21702
+ }
21703
+ var WORDS_CACHE = /* @__PURE__ */ new Map();
21704
+ /** Normalised once per entry — search runs on every keystroke over ~1,700 entries. */
21705
+ function wordsOf(entry) {
21706
+ const cached = WORDS_CACHE.get(entry.key);
21707
+ if (cached !== void 0) return cached;
21708
+ const built = {
21709
+ key: normalise(entry.key),
21710
+ synonyms: entry.synonyms.map(normalise),
21711
+ tags: entry.tags.map(normalise)
21712
+ };
21713
+ WORDS_CACHE.set(entry.key, built);
21714
+ return built;
21715
+ }
21716
+ /**
21717
+ * Rank the catalog against a free-text query.
21718
+ *
21719
+ * An empty query returns the WHOLE catalog in key order, because a picker's
21720
+ * resting state is the full list. `extraWords` lets a caller add the words IT
21721
+ * can translate without this module knowing what a locale is; they score like a
21722
+ * synonym. The key outranks a synonym, a synonym outranks a lucide tag, and
21723
+ * ties keep key order so the list does not reshuffle between keystrokes.
21724
+ */
21725
+ function searchIcons(query, extraWords) {
21726
+ const q = normalise(query);
21727
+ if (q.length === 0) return ENTRIES.map((entry) => ({
21728
+ entry,
21729
+ score: 0
21730
+ }));
21731
+ const hits = [];
21732
+ for (const entry of ENTRIES) {
21733
+ const words = wordsOf(entry);
21734
+ const keyScore = scoreWord(q, words.key, true);
21735
+ let best = keyScore > 0 ? keyScore + 2 : 0;
21736
+ for (const word of [...words.synonyms, ...(extraWords?.get(entry.key) ?? []).map(normalise)]) {
21737
+ const score = scoreWord(q, word, true);
21738
+ if (score > 0 && score + 1 > best) best = score + 1;
21739
+ }
21740
+ for (const word of words.tags) {
21741
+ const score = scoreWord(q, word, false);
21742
+ if (score > best) best = score;
21743
+ }
21744
+ if (best > 0) hits.push({
21745
+ entry,
21746
+ score: best
21747
+ });
21748
+ }
21749
+ return hits.toSorted((a, b) => b.score - a.score);
21750
+ }
21751
+ /** A readable label derived from a lucide category id (`food-beverage` → `Food beverage`). */
21752
+ function iconCategoryLabel(category) {
21753
+ const spaced = category.replace(/-/g, " ");
21754
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
21755
+ }
21756
+ //#endregion
21757
+ //#region src/device/location-icon.ts
21758
+ /**
21759
+ * What a location's icon is, and the ONE key its label joins on.
21760
+ *
21761
+ * A location is not an entity — it is a free-form label (`PersistedDeviceMeta.
21762
+ * location`) plus an operator-curated registry of labels in the device-manager
21763
+ * addon store. The icon is a second map beside that registry, keyed the same
21764
+ * way the registry matches everywhere else: {@link locationIconKey}, i.e.
21765
+ * trimmed + lower-cased.
21766
+ *
21767
+ * The icon itself is a key of the ONE icon catalog (`icon-catalog.ts`, D854):
21768
+ * a location may wear any icon both surfaces can draw. There used to be a
21769
+ * closed 20-icon subset here (D511); it was a second vocabulary on the same two
21770
+ * surfaces and is gone. The names below keep their meaning:
21771
+ *
21772
+ * DEGRADATION IS A FALLBACK, NEVER A HOLE. A surface that cannot draw a stored
21773
+ * value — a persisted value from a newer hub, a hand-edited settings row —
21774
+ * draws {@link LOCATION_ICON_FALLBACK} (`map-pin`, the one every surface has
21775
+ * always had). Read a stored/wire value through
21776
+ * `resolveIconKey(value, LOCATION_ICON_FALLBACK)` and that is what you get;
21777
+ * nothing anywhere renders an empty box.
21778
+ */
21779
+ /**
21780
+ * The id every surface is guaranteed to draw, and what an unrecognised value
21781
+ * degrades to. It has been in both icon sets since before the catalog existed
21782
+ * (the Locations page's own page icon), so "the fallback is missing" is not a
21783
+ * state a build can reach.
21784
+ */
21785
+ var LOCATION_ICON_FALLBACK = "map-pin";
21786
+ zod.z.object({
21787
+ locationKey: zod.z.string(),
21788
+ icon: IconKeySchema
21789
+ });
21790
+ /**
21791
+ * The join key for a location label: trimmed, lower-cased. The ONE place this
21792
+ * derivation lives — a caller that re-spells it is a rename away from an
21793
+ * orphaned icon.
21794
+ */
21795
+ function locationIconKey(label) {
21796
+ return label.trim().toLowerCase();
21797
+ }
21798
+ zod.z.object({
21799
+ locationKey: zod.z.string(),
21800
+ description: zod.z.string()
21801
+ });
21802
+ //#endregion
21803
+ //#region src/automation/action-target.ts
21804
+ /**
21805
+ * WHO an action step acts on (D983) — one target shape for every place that
21806
+ * performs an action (automation steps, Action entries, notification
21807
+ * sequences, scheduler points):
21808
+ *
21809
+ * - `devices` — device ids the operator picked, frozen at save time;
21810
+ * - `domain` — every device that offers the step's VOCABULARY action,
21811
+ * optionally narrowed to one device type and one room, resolved WHEN THE
21812
+ * STEP RUNS (a device added later is included). The action always comes
21813
+ * from the vocabulary (`cover.open`), never from a raw cap name, so a
21814
+ * `domain` target needs an `actionId` (enforced on the step schema);
21815
+ * - `var` — a `$var` resolved from the run's scope.
21816
+ *
21817
+ * {@link ActionTargetResolver} turns a target into device ids. It is pure
21818
+ * (a {@link DeviceDirectory} is handed in) so the hub's core-blocks service and
21819
+ * a generated automation block share it.
21820
+ *
21821
+ * A room is the device's `location` label (`DeviceInfo.location`); two labels
21822
+ * name the same room when `locationIconKey` agrees (trimmed, lower-cased).
21823
+ */
21824
+ var ActionTargetSchema = zod.z.discriminatedUnion("kind", [
21825
+ zod.z.object({
21826
+ kind: zod.z.literal("devices"),
21827
+ ids: zod.z.array(zod.z.number().int()).min(1)
21828
+ }),
21829
+ zod.z.object({
21830
+ kind: zod.z.literal("domain"),
21831
+ /** Narrow to one kind of device. */
21832
+ deviceType: zod.z.enum(DeviceType).optional(),
21833
+ /** Narrow to one room: the `location` label devices carry. */
21834
+ roomId: zod.z.string().min(1).optional()
21835
+ }),
21836
+ zod.z.object({
21837
+ kind: zod.z.literal("var"),
21838
+ name: zod.z.string().min(1)
21839
+ })
21840
+ ]);
21841
+ EventCategory.DeviceRegistered, EventCategory.DeviceUnregistered, EventCategory.DeviceUpdated, EventCategory.DeviceMetaChanged, EventCategory.DeviceBindingsChanged;
21842
+ //#endregion
21843
+ //#region src/catalogs/sensor-active-state.ts
21844
+ /**
21845
+ * THE table "which boolean of this device type counts as HIGH", and the
21846
+ * pure evaluator of its EDGE.
21847
+ *
21848
+ * ONE table for everyone who asks "is this device HIGH?": the
21849
+ * recorder's `RecordingTriggers.sensorDeviceIds` trigger, the post-analysis
21850
+ * sensor markers, the orchestrator's camera-sensors bridge and
21851
+ * the sources of a pulse (`compilePulseOverride`, D936).
21852
+ * A second copy would diverge at the first cap added, and the symptom — "the
21853
+ * sensor rings the doorbell but does not record" — is exactly D62.
21854
+ *
21855
+ * ⚠ NOT `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), and the
21856
+ * two must not be unified: that one answers "what is the state WORD for a
21857
+ * rule" (and includes `presence`, `cover`, `alarm-panel`), this one "which
21858
+ * boolean's EDGE counts". Deliberately different vocabularies.
21378
21859
  */
21379
- var LEGACY_ICON_ALIASES = { gauge2: "gauge" };
21860
+ /**
21861
+ * Known binary / switch source caps → the boolean slice field whose
21862
+ * false→true rise counts as ACTIVE. Every entry is "fire on active".
21863
+ * Sensors whose "active" reading is not a plain boolean (presence's string
21864
+ * state, connectivity's connected flag) are deliberately excluded — a
21865
+ * reconnect is not a doorbell press, and it is not a recording either.
21866
+ */
21867
+ var SOURCE_CAP_ACTIVE_FIELD = {
21868
+ contact: "entryOpen",
21869
+ binary: "on",
21870
+ switch: "on",
21871
+ motion: "detected",
21872
+ flood: "flooded",
21873
+ gas: "detected",
21874
+ smoke: "detected",
21875
+ "carbon-monoxide": "detected",
21876
+ vibration: "detected",
21877
+ tamper: "tampered"
21878
+ };
21380
21879
  //#endregion
21381
- //#region src/device/icon-catalog.ts
21880
+ //#region src/catalogs/coco-classmap.ts
21881
+ var MACRO_LABELS = [
21882
+ {
21883
+ id: "person",
21884
+ name: "Person"
21885
+ },
21886
+ {
21887
+ id: "vehicle",
21888
+ name: "Vehicle"
21889
+ },
21890
+ {
21891
+ id: "animal",
21892
+ name: "Animal"
21893
+ },
21894
+ {
21895
+ id: "package",
21896
+ name: "Package"
21897
+ }
21898
+ ];
21899
+ new Set(MACRO_LABELS.map((l) => l.id));
21900
+ var COCO_TO_MACRO = {
21901
+ mapping: {
21902
+ person: "person",
21903
+ bicycle: "vehicle",
21904
+ car: "vehicle",
21905
+ motorcycle: "vehicle",
21906
+ airplane: "vehicle",
21907
+ bus: "vehicle",
21908
+ train: "vehicle",
21909
+ truck: "vehicle",
21910
+ boat: "vehicle",
21911
+ bird: "animal",
21912
+ cat: "animal",
21913
+ dog: "animal",
21914
+ horse: "animal",
21915
+ sheep: "animal",
21916
+ cow: "animal",
21917
+ elephant: "animal",
21918
+ bear: "animal",
21919
+ zebra: "animal",
21920
+ giraffe: "animal",
21921
+ suitcase: "package",
21922
+ backpack: "package",
21923
+ handbag: "package"
21924
+ },
21925
+ preserveOriginal: false
21926
+ };
21927
+ zod.z.object({
21928
+ field: zod.z.string().min(1),
21929
+ on: zod.z.literal("advance")
21930
+ });
21382
21931
  /**
21383
- * icon-catalog — THE icon vocabulary (D854).
21384
- *
21385
- * Every place that picks or stores an icon — a device's display override, a
21386
- * location, a site zone, a viewer accessory, an `icon` settings field — names
21387
- * it by a key of this catalog and nothing else. There is no curated subset and
21388
- * no second list: the catalog is every lucide icon that both surfaces can draw
21389
- * (the admin UI's `lucide-react` and the viewer's `lucide-react-native`), the
21390
- * intersection computed by `scripts/generate-icon-catalog.ts` into the
21391
- * committed `icon-catalog.generated.ts`.
21932
+ * Output schema shared by the contribution + live methods.
21392
21933
  *
21393
- * Like the vocabularies it replaces, this module holds STRINGS only — no React,
21394
- * no lucide import, no glyph. Each surface owns its glyph table (the web one
21395
- * lives in `ui-library`, loaded lazily), and a test on each side pins that it
21396
- * covers {@link ICON_KEYS}.
21934
+ * Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
21935
+ * tabs[]) without importing from `../interfaces/config-ui.js` — a
21936
+ * concrete-but-lenient Zod object keeps tRPC output inference happy
21937
+ * (using `z.unknown()` here collapses unrelated router branches to
21938
+ * `unknown` when the generator re-inlines the huge AppRouter type).
21397
21939
  *
21398
- * ## What is persisted
21940
+ * `.passthrough()` on sections/fields accepts whatever FormBuilder
21941
+ * extensions the caller adds (showWhen, displayScale, …) without
21942
+ * rebuilding every time a new field kind is introduced.
21943
+ */
21944
+ var ContributionSectionSchema = zod.z.object({
21945
+ id: zod.z.string(),
21946
+ title: zod.z.string(),
21947
+ description: zod.z.string().optional(),
21948
+ style: zod.z.enum(["card", "accordion"]).optional(),
21949
+ defaultCollapsed: zod.z.boolean().optional(),
21950
+ columns: zod.z.union([
21951
+ zod.z.literal(1),
21952
+ zod.z.literal(2),
21953
+ zod.z.literal(3),
21954
+ zod.z.literal(4)
21955
+ ]).optional(),
21956
+ tab: zod.z.string().optional(),
21957
+ location: zod.z.enum([
21958
+ "settings",
21959
+ "top-tab",
21960
+ "hero"
21961
+ ]).optional(),
21962
+ order: zod.z.number().optional(),
21963
+ fields: zod.z.array(zod.z.any())
21964
+ });
21965
+ var ContributionTabSchema = zod.z.object({
21966
+ id: zod.z.string(),
21967
+ label: zod.z.string(),
21968
+ icon: zod.z.string(),
21969
+ order: zod.z.number().optional()
21970
+ });
21971
+ zod.z.object({
21972
+ tabs: zod.z.array(ContributionTabSchema).optional(),
21973
+ sections: zod.z.array(ContributionSectionSchema)
21974
+ }).nullable();
21975
+ zod.z.object({ deviceId: zod.z.number() }), zod.z.object({ deviceId: zod.z.number() }), zod.z.object({
21976
+ deviceId: zod.z.number(),
21977
+ patch: zod.z.record(zod.z.string(), zod.z.unknown())
21978
+ }), zod.z.object({ success: zod.z.literal(true) });
21979
+ zod.z.object({ deviceId: zod.z.number() }), zod.z.unknown().nullable();
21980
+ function method(input, output, options) {
21981
+ return {
21982
+ input,
21983
+ output,
21984
+ kind: options?.kind ?? "query",
21985
+ ...options?.auth !== void 0 ? { auth: options.auth } : {},
21986
+ ...options?.access !== void 0 ? { access: options.access } : {},
21987
+ ...options?.role !== void 0 ? { role: options.role } : {},
21988
+ ...options?.caller !== void 0 ? { caller: options.caller } : {},
21989
+ timeoutMs: options?.timeoutMs
21990
+ };
21991
+ }
21992
+ /**
21993
+ * A method a SOURCE of a collection cap may legitimately not serve — OPTIONAL
21994
+ * on `InferProvider`. The `providerOptional: true` literal is what
21995
+ * `InferProvider` keys on; see {@link CapabilityMethodSchema.providerOptional}
21996
+ * for when this is the honest answer and when it is a soft stub.
21997
+ */
21998
+ function optionalMethod(input, output, options) {
21999
+ return {
22000
+ ...method(input, output, options),
22001
+ providerOptional: true
22002
+ };
22003
+ }
22004
+ //#endregion
22005
+ //#region src/capabilities/notification-output.cap.ts
22006
+ /**
22007
+ * notification-output — canonical, capability-gated notification delivery.
21399
22008
  *
21400
- * The canonical kebab-case lucide id ({@link IconKey}). Spellings that a lucide
21401
- * package still accepts (`home` for `house`) and this repo's own legacy ones
21402
- * (`gauge2`) RESOLVE ({@link canonicalIconKey}) so a stored value never goes
21403
- * stale, but they are never OFFERED by search.
22009
+ * Apprise-derived model (see
22010
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
22011
+ * callers emit ONE canonical `Notification`; each provider declares a
22012
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
22013
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
22014
+ * message to what the kind supports — callers never special-case a service.
21404
22015
  *
21405
- * ## Search
22016
+ * DESIGN DECISIONS (locked):
22017
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
22018
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
22019
+ * cap. Rationale: the admin UI needs one uniform surface across the
22020
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
22021
+ * alternative would fork the UI per addon and cannot host the
22022
+ * discovery→adopt flow.
22023
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
22024
+ * the generated cap-mount auto-`concatCollection`-fans them across every
22025
+ * registered provider (notifiers addon + HA addon) so one catalog is
22026
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
22027
+ * `addonId` the generated collection router extracts from the call input.
22028
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
22029
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
22030
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
22031
+ * base64 fallback needed.
21406
22032
  *
21407
- * Over the key, the Italian/English overlay in `icon-synonyms.ts`, any words
21408
- * the caller adds, and lucide's English tags — in that order of weight.
22033
+ * TODO (deferred, closed-set change — separate decision): add
22034
+ * `providerKind: 'notify'` so notification providers surface on the unified
22035
+ * admin "Integrations" page.
21409
22036
  */
21410
- var KEY_SET$1 = new Set(ICON_KEYS);
21411
- var ALIAS_TO_KEY = new Map([...Object.entries(ICON_ALIASES), ...Object.entries(LEGACY_ICON_ALIASES)]);
21412
- /** Is this a canonical key of the catalog? (An alias is not — see {@link canonicalIconKey}.) */
21413
- function isIconKey(value) {
21414
- return typeof value === "string" && KEY_SET$1.has(value);
21415
- }
21416
22037
  /**
21417
- * A stored value as a catalog key: the key itself, the key a still-accepted or
21418
- * legacy spelling stands for, or `undefined` — never a guess.
22038
+ * Zentik-derived typed-media enum — the superset across every kind. Each
22039
+ * adapter picks what it supports and the degrade engine filters the rest.
21419
22040
  */
21420
- function canonicalIconKey(value) {
21421
- if (typeof value !== "string") return void 0;
21422
- if (isIconKey(value)) return value;
21423
- return ALIAS_TO_KEY.get(value);
21424
- }
22041
+ var AttachmentMediaTypeSchema = zod.z.enum([
22042
+ "image",
22043
+ "video",
22044
+ "gif",
22045
+ "audio",
22046
+ "icon"
22047
+ ]);
21425
22048
  /**
21426
- * A stored icon value as a catalog key: its canonical key when it is (or
21427
- * aliases) one, `fallback` otherwise. For an `icon` settings field whose stored
21428
- * value may predate the catalog (a free-text lucide name) — the field's default
21429
- * is drawn rather than nothing.
22049
+ * A single attachment. Exactly one of `url` (remote source, most adapters
22050
+ * prefer this) or `bytes` (inline source; required for Pushover-style
22051
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
22052
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
21430
22053
  */
21431
- function resolveIconKey(value, fallback) {
21432
- return canonicalIconKey(value) ?? fallback;
21433
- }
22054
+ var AttachmentSchema = zod.z.object({
22055
+ mediaType: AttachmentMediaTypeSchema,
22056
+ url: zod.z.string().optional(),
22057
+ bytes: zod.z.instanceof(Uint8Array).optional(),
22058
+ mime: zod.z.string().optional(),
22059
+ name: zod.z.string().optional()
22060
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
22061
+ var NotificationFormatSchema = zod.z.enum([
22062
+ "text",
22063
+ "markdown",
22064
+ "html"
22065
+ ]);
21434
22066
  /**
21435
- * Wire/storage schema for one icon key.
22067
+ * The CLOSED icon vocabulary an action button may use.
21436
22068
  *
21437
- * Deliberately NOT a `z.enum` of the catalog: ~1,700 literals would be inlined
21438
- * into every cap router, type and API-reference row that carries an icon. The
21439
- * check is membership in the catalog and the type is {@link IconKey}.
22069
+ * A closed set, not a free string, and that is the whole point: an arbitrary
22070
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
22071
+ * notices — the same class of gap as a zone vocabulary nothing produced
22072
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
22073
+ * declares `actionIcons: false` and the degrade engine strips the field.
22074
+ *
22075
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
22076
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
22077
+ * differently.
22078
+ *
22079
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
22080
+ *
22081
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
22082
+ * the whole set onto its own renderer's vocabulary through a
22083
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
22084
+ * fallback, so adding a member here fails every adapter's build until someone
22085
+ * decides its glyph, which is the only place that decision can be made
22086
+ * honestly.
22087
+ *
22088
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
22089
+ * `disarm` straight through; iOS feeds that string to
22090
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
22091
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
22092
+ * mapping, and "the field is documented" is not "the value renders".
22093
+ *
22094
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
22095
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
22096
+ * so an addon that emits a token the running hub does not know does not lose an
22097
+ * icon — its whole `send` fails Zod validation and the notification never
22098
+ * arrives. Never emit a new token from an addon before the train carrying it.
21440
22099
  */
21441
- var IconKeySchema = zod.z.custom(isIconKey, "Not an icon of the catalog");
21442
- var ENTRIES = ICON_KEYS.map((key, index) => {
21443
- const [categoryIndexes, tags] = ICON_META[index] ?? [[], []];
21444
- const categories = categoryIndexes.flatMap((i) => {
21445
- const id = ICON_CATEGORY_IDS[i];
21446
- return id === void 0 ? [] : [id];
21447
- });
21448
- return {
21449
- key,
21450
- categories,
21451
- primaryCategory: categories[0] ?? ICON_CATEGORY_IDS[0],
21452
- tags,
21453
- synonyms: ICON_SYNONYMS[key] ?? []
21454
- };
22100
+ var NotificationActionIconSchema = zod.z.enum([
22101
+ "acknowledge",
22102
+ "dismiss",
22103
+ "silence",
22104
+ "snooze",
22105
+ "view",
22106
+ "play",
22107
+ "open",
22108
+ "close",
22109
+ "lock",
22110
+ "unlock",
22111
+ "arm",
22112
+ "arm-home",
22113
+ "arm-away",
22114
+ "arm-night",
22115
+ "disarm",
22116
+ "light",
22117
+ "alert",
22118
+ "camera"
22119
+ ]);
22120
+ /** A single tap-through action button. */
22121
+ var NotificationActionSchema = zod.z.object({
22122
+ id: zod.z.string(),
22123
+ label: zod.z.string(),
22124
+ url: zod.z.string().optional(),
22125
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
22126
+ icon: NotificationActionIconSchema.optional(),
22127
+ /**
22128
+ * Renders in a warning style where the notifier supports it.
22129
+ *
22130
+ * A HINT, never a gate. The callback's authority is its token and nothing
22131
+ * else — see `notification-center/action-token.ts` for what that does and
22132
+ * does not buy.
22133
+ */
22134
+ destructive: zod.z.boolean().optional(),
22135
+ /**
22136
+ * How the tap should REACH the url.
22137
+ *
22138
+ * `navigate` (absent, and every button authored before this field) opens it:
22139
+ * the phone leaves the notification and shows whatever the callback returns.
22140
+ * That is right for a button whose answer the operator wants to read.
22141
+ *
22142
+ * `background` fires it as a POST and stays put. It exists for the buttons
22143
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
22144
+ * an answer to the notification, and being thrown into a browser tab to
22145
+ * confirm it costs more attention than the notification did. A backend that
22146
+ * cannot do a background call renders it as an ordinary link (the adapters
22147
+ * fall back rather than dropping the button), so this is a preference, never
22148
+ * a requirement.
22149
+ */
22150
+ mode: zod.z.enum(["navigate", "background"]).optional()
21455
22151
  });
21456
- var ENTRY_BY_KEY = new Map(ENTRIES.map((e) => [e.key, e]));
21457
- /** The entry for a canonical key, or `undefined`. */
21458
- function iconEntry(key) {
21459
- return ENTRY_BY_KEY.get(key);
21460
- }
21461
- /** Every entry, in key order. */
21462
- var ICON_ENTRIES = ENTRIES;
21463
- /** Categories a picker offers first: the ones this product's icons mostly live in. */
21464
- var LEADING_CATEGORIES = [
21465
- "devices",
21466
- "home",
21467
- "security",
21468
- "buildings",
21469
- "connectivity",
21470
- "multimedia",
21471
- "nature",
21472
- "weather",
21473
- "transportation",
21474
- "animals"
21475
- ];
21476
- var PRIMARY_CATEGORIES = new Set(ENTRIES.map((e) => e.primaryCategory));
21477
- /**
21478
- * The categories a picker files icons under, in picker order: the leading ones,
21479
- * then the rest alphabetically. Only categories that are some icon's PRIMARY
21480
- * one — a category lucide lists only as a secondary has no section of its own.
21481
- */
21482
- var ICON_CATEGORIES = [...LEADING_CATEGORIES, ...ICON_CATEGORY_IDS.filter((c) => !LEADING_CATEGORIES.includes(c))].filter((c) => PRIMARY_CATEGORIES.has(c));
21483
- /** The icons filed under a category as their PRIMARY one, in key order. */
21484
- function iconsInCategory(category) {
21485
- return ENTRIES.filter((e) => e.primaryCategory === category);
21486
- }
21487
- function normalise(text) {
21488
- return text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase().replace(/[_-]+/g, " ").trim();
21489
- }
21490
- /** Does `needle` appear in `haystack` in order, though not adjacently? */
21491
- function isSubsequence(needle, haystack) {
21492
- let i = 0;
21493
- for (const ch of haystack) {
21494
- if (ch === needle[i]) i += 1;
21495
- if (i === needle.length) return true;
21496
- }
21497
- return needle.length === 0;
21498
- }
22152
+ /** A delivery parameter value: what a notifier declared it takes. */
22153
+ var DeliveryParamValueSchema = zod.z.union([
22154
+ zod.z.string(),
22155
+ zod.z.number(),
22156
+ zod.z.boolean()
22157
+ ]);
21499
22158
  /**
21500
- * How well one word answers the query. 0 = not at all. An exact word beats a
21501
- * prefix, a prefix beats a word STARTING inside the phrase, and a loose
21502
- * subsequence comes last because it matches almost anything.
22159
+ * One extra delivery parameter a notifier KIND declares (D938). A rule that
22160
+ * addresses a target of that kind may set it; a kind that declares nothing is
22161
+ * never handed one. The admin UI derives its control from this descriptor —
22162
+ * no editor knows a service by name.
22163
+ *
22164
+ * Optional fields are `.optional()`, never `.default()` (a Zod default does not
22165
+ * run on the addon cap path).
21503
22166
  */
21504
- function scoreWord(query, word, loose) {
21505
- if (word === query) return 100;
21506
- if (word.startsWith(query)) return 80;
21507
- if (word.split(" ").some((w) => w.startsWith(query))) return 60;
21508
- if (word.includes(query)) return 40;
21509
- if (loose && isSubsequence(query, word)) return 15;
21510
- return 0;
21511
- }
21512
- var WORDS_CACHE = /* @__PURE__ */ new Map();
21513
- /** Normalised once per entry — search runs on every keystroke over ~1,700 entries. */
21514
- function wordsOf(entry) {
21515
- const cached = WORDS_CACHE.get(entry.key);
21516
- if (cached !== void 0) return cached;
21517
- const built = {
21518
- key: normalise(entry.key),
21519
- synonyms: entry.synonyms.map(normalise),
21520
- tags: entry.tags.map(normalise)
21521
- };
21522
- WORDS_CACHE.set(entry.key, built);
21523
- return built;
21524
- }
22167
+ var DeliveryParamSchema = zod.z.object({
22168
+ key: zod.z.string().min(1),
22169
+ label: zod.z.string(),
22170
+ /** i18n key of the label, resolved by the declaring addon's namespace. */
22171
+ labelKey: zod.z.string().optional(),
22172
+ type: zod.z.enum([
22173
+ "enum",
22174
+ "number",
22175
+ "boolean",
22176
+ "string"
22177
+ ]),
22178
+ /** `enum` only: the closed set of accepted values. */
22179
+ options: zod.z.array(zod.z.object({
22180
+ value: zod.z.string(),
22181
+ label: zod.z.string()
22182
+ })).optional(),
22183
+ /** `number` only. */
22184
+ min: zod.z.number().optional(),
22185
+ max: zod.z.number().optional(),
22186
+ /** What the notifier does when the rule sets nothing; shown as the placeholder. */
22187
+ default: DeliveryParamValueSchema.optional()
22188
+ });
21525
22189
  /**
21526
- * Rank the catalog against a free-text query.
21527
- *
21528
- * An empty query returns the WHOLE catalog in key order, because a picker's
21529
- * resting state is the full list. `extraWords` lets a caller add the words IT
21530
- * can translate without this module knowing what a locale is; they score like a
21531
- * synonym. The key outranks a synonym, a synonym outranks a lucide tag, and
21532
- * ties keep key order so the list does not reshuffle between keystrokes.
22190
+ * The canonical notification. `body` is the only hard field (Apprise model).
22191
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
22192
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
22193
+ * the adapter maps this ordinal onto its native level. `level?` is an
22194
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
22195
+ * `priority` for that one target.
21533
22196
  */
21534
- function searchIcons(query, extraWords) {
21535
- const q = normalise(query);
21536
- if (q.length === 0) return ENTRIES.map((entry) => ({
21537
- entry,
21538
- score: 0
21539
- }));
21540
- const hits = [];
21541
- for (const entry of ENTRIES) {
21542
- const words = wordsOf(entry);
21543
- const keyScore = scoreWord(q, words.key, true);
21544
- let best = keyScore > 0 ? keyScore + 2 : 0;
21545
- for (const word of [...words.synonyms, ...(extraWords?.get(entry.key) ?? []).map(normalise)]) {
21546
- const score = scoreWord(q, word, true);
21547
- if (score > 0 && score + 1 > best) best = score + 1;
21548
- }
21549
- for (const word of words.tags) {
21550
- const score = scoreWord(q, word, false);
21551
- if (score > best) best = score;
21552
- }
21553
- if (best > 0) hits.push({
21554
- entry,
21555
- score: best
21556
- });
21557
- }
21558
- return hits.toSorted((a, b) => b.score - a.score);
21559
- }
21560
- /** A readable label derived from a lucide category id (`food-beverage` → `Food beverage`). */
21561
- function iconCategoryLabel(category) {
21562
- const spaced = category.replace(/-/g, " ");
21563
- return spaced.charAt(0).toUpperCase() + spaced.slice(1);
21564
- }
21565
- //#endregion
21566
- //#region src/device/location-icon.ts
22197
+ var NotificationSchema = zod.z.object({
22198
+ body: zod.z.string(),
22199
+ title: zod.z.string().optional(),
22200
+ format: NotificationFormatSchema.default("text"),
22201
+ priority: zod.z.number().int().min(1).max(5).default(3),
22202
+ level: zod.z.string().optional(),
22203
+ attachments: zod.z.array(AttachmentSchema).optional(),
22204
+ clickUrl: zod.z.string().optional(),
22205
+ actions: zod.z.array(NotificationActionSchema).optional(),
22206
+ sound: zod.z.string().optional(),
22207
+ ttl: zod.z.number().optional(),
22208
+ tag: zod.z.string().optional(),
22209
+ deviceId: zod.z.number().optional(),
22210
+ eventId: zod.z.string().optional(),
22211
+ metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
22212
+ /**
22213
+ * Values for the parameters the target's kind DECLARES in
22214
+ * {@link TargetKindSchema} `deliveryParams`. Already validated against the
22215
+ * declaration by the sender; a notifier reads only the keys it declared.
22216
+ */
22217
+ delivery: zod.z.record(zod.z.string(), DeliveryParamValueSchema).optional()
22218
+ });
22219
+ /** One declared native severity/priority level for a kind. */
22220
+ var TargetKindLevelSchema = zod.z.object({
22221
+ id: zod.z.string(),
22222
+ label: zod.z.string(),
22223
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
22224
+ ordinal: zod.z.number().int().min(1).max(5).nullable(),
22225
+ flags: zod.z.object({
22226
+ critical: zod.z.boolean().optional(),
22227
+ silent: zod.z.boolean().optional(),
22228
+ noPush: zod.z.boolean().optional()
22229
+ }).optional(),
22230
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
22231
+ requires: zod.z.array(zod.z.string()).optional(),
22232
+ description: zod.z.string().optional()
22233
+ });
22234
+ /** Attachment capabilities for a kind (drives the degrade engine + test panel). */
22235
+ var TargetKindAttachmentsCapsSchema = zod.z.object({
22236
+ mediaTypes: zod.z.array(AttachmentMediaTypeSchema),
22237
+ mode: zod.z.enum([
22238
+ "url",
22239
+ "bytes",
22240
+ "both"
22241
+ ]),
22242
+ max: zod.z.number().int().nonnegative(),
22243
+ maxBytes: zod.z.number().int().positive().optional()
22244
+ });
22245
+ /** The full capability block consulted before dispatch. */
22246
+ var TargetKindCapsSchema = zod.z.object({
22247
+ attachments: TargetKindAttachmentsCapsSchema,
22248
+ /** Max action buttons (0 = none). */
22249
+ actions: zod.z.number().int().nonnegative(),
22250
+ /**
22251
+ * Whether this kind renders a per-action ICON.
22252
+ *
22253
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
22254
+ * run on the addon cap path — three production failures in one day taught
22255
+ * this repo that once. Absent is read as false by the degrade engine, which
22256
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
22257
+ * assumed and dropped costs the operator's trust in the field.
22258
+ */
22259
+ actionIcons: zod.z.boolean().optional(),
22260
+ levels: zod.z.array(TargetKindLevelSchema),
22261
+ format: zod.z.array(NotificationFormatSchema),
22262
+ clickUrl: zod.z.boolean(),
22263
+ sound: zod.z.boolean(),
22264
+ ttl: zod.z.boolean(),
22265
+ bodyMaxLen: zod.z.number().int().positive()
22266
+ });
21567
22267
  /**
21568
- * What a location's icon is, and the ONE key its label joins on.
21569
- *
21570
- * A location is not an entity — it is a free-form label (`PersistedDeviceMeta.
21571
- * location`) plus an operator-curated registry of labels in the device-manager
21572
- * addon store. The icon is a second map beside that registry, keyed the same
21573
- * way the registry matches everywhere else: {@link locationIconKey}, i.e.
21574
- * trimmed + lower-cased.
21575
- *
21576
- * The icon itself is a key of the ONE icon catalog (`icon-catalog.ts`, D854):
21577
- * a location may wear any icon both surfaces can draw. There used to be a
21578
- * closed 20-icon subset here (D511); it was a second vocabulary on the same two
21579
- * surfaces and is gone. The names below keep their meaning:
21580
- *
21581
- * DEGRADATION IS A FALLBACK, NEVER A HOLE. A surface that cannot draw a stored
21582
- * value — a persisted value from a newer hub, a hand-edited settings row —
21583
- * draws {@link LOCATION_ICON_FALLBACK} (`map-pin`, the one every surface has
21584
- * always had). Read a stored/wire value through
21585
- * `resolveIconKey(value, LOCATION_ICON_FALLBACK)` and that is what you get;
21586
- * nothing anywhere renders an empty box.
22268
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
22269
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
22270
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
22271
+ * the union is large and not meant for runtime validation here; the exported
22272
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
21587
22273
  */
22274
+ var ConfigSchemaPassthrough = zod.z.unknown();
22275
+ var TargetKindSchema = zod.z.object({
22276
+ kind: zod.z.string(),
22277
+ label: zod.z.string(),
22278
+ icon: zod.z.string(),
22279
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
22280
+ addonId: zod.z.string(),
22281
+ /**
22282
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
22283
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
22284
+ * when the addon bundles no icon for that kind — the client then falls back
22285
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
22286
+ *
22287
+ * Root-relative on purpose: it resolves against whatever origin serves a web
22288
+ * client, and a native client joins it onto its own hub base.
22289
+ *
22290
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
22291
+ * field that survived only because the runtime cap-router forwards provider
22292
+ * output verbatim — so every consumer had to re-declare it by hand to stop
22293
+ * its own Zod parse from stripping it, and the whole arrangement would have
22294
+ * broken silently the moment output validation was tightened anywhere.
22295
+ */
22296
+ iconUrl: zod.z.string().optional(),
22297
+ /**
22298
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
22299
+ *
22300
+ * The server knows this and therefore says it, because the client cannot
22301
+ * safely guess: a React-Native client renders SVG and raster through two
22302
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
22303
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
22304
+ * placeholder glyph for every vector icon while the web build looked fine.
22305
+ *
22306
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
22307
+ * not been updated — a client that cannot determine the type should prefer
22308
+ * its raster path, which is the safe default for an unknown image.
22309
+ */
22310
+ iconMediaType: zod.z.string().optional(),
22311
+ configSchema: ConfigSchemaPassthrough,
22312
+ /**
22313
+ * The config keys that decide WHERE this kind's secrets are sent (server,
22314
+ * base URL, instance, chat, recipient). A save that changes any of them
22315
+ * while leaving a secret redacted is refused by the provider (D634), so an
22316
+ * editor uses this to ask for the secret on exactly those edits instead of
22317
+ * on every edit.
22318
+ *
22319
+ * Optional: absent from a provider that holds no secret (the Home Assistant
22320
+ * kinds) and from a provider older than this field — an editor that finds
22321
+ * it absent must assume ANY edit may need the secret.
22322
+ */
22323
+ destinationKeys: zod.z.array(zod.z.string()).optional(),
22324
+ supportsDiscovery: zod.z.boolean(),
22325
+ /**
22326
+ * Extra delivery parameters a rule may set for a target of this kind (D938).
22327
+ * Absent = none; the rule editor then offers nothing.
22328
+ */
22329
+ deliveryParams: zod.z.array(DeliveryParamSchema).optional(),
22330
+ caps: TargetKindCapsSchema
22331
+ });
21588
22332
  /**
21589
- * The id every surface is guaranteed to draw, and what an unrecognised value
21590
- * degrades to. It has been in both icon sets since before the catalog existed
21591
- * (the Locations page's own page icon), so "the fallback is missing" is not a
21592
- * state a build can reach.
22333
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
22334
+ * (return a presence marker only) when serving `listTargets` — never
22335
+ * round-trip a stored secret to the UI.
21593
22336
  */
21594
- var LOCATION_ICON_FALLBACK = "map-pin";
21595
- zod.z.object({
21596
- locationKey: zod.z.string(),
21597
- icon: IconKeySchema
22337
+ var TargetSchema = zod.z.object({
22338
+ id: zod.z.string(),
22339
+ name: zod.z.string(),
22340
+ kind: zod.z.string(),
22341
+ addonId: zod.z.string(),
22342
+ enabled: zod.z.boolean(),
22343
+ config: zod.z.record(zod.z.string(), zod.z.unknown())
22344
+ });
22345
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
22346
+ var DiscoveredTargetSchema = zod.z.object({
22347
+ kind: zod.z.string(),
22348
+ suggestedName: zod.z.string(),
22349
+ config: zod.z.record(zod.z.string(), zod.z.unknown())
22350
+ });
22351
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
22352
+ var RenderedAsSchema = zod.z.object({
22353
+ level: zod.z.string(),
22354
+ format: NotificationFormatSchema,
22355
+ attachmentsSent: zod.z.number().int().nonnegative(),
22356
+ actionsSent: zod.z.number().int().nonnegative(),
22357
+ truncated: zod.z.boolean(),
22358
+ dropped: zod.z.array(zod.z.string())
22359
+ });
22360
+ var SendResultSchema = zod.z.object({
22361
+ success: zod.z.boolean(),
22362
+ error: zod.z.string().optional(),
22363
+ renderedAs: RenderedAsSchema.optional()
21598
22364
  });
22365
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
22366
+ var TestResultSchema = SendResultSchema;
21599
22367
  /**
21600
- * The join key for a location label: trimmed, lower-cased. The ONE place this
21601
- * derivation lives — a caller that re-spells it is a rename away from an
21602
- * orphaned icon.
22368
+ * Pre-save credential probe against a partial (unsaved) target config.
22369
+ * `resolvedUrl` never includes secrets (token stripped from path).
21603
22370
  */
21604
- function locationIconKey(label) {
21605
- return label.trim().toLowerCase();
21606
- }
21607
- zod.z.object({
21608
- locationKey: zod.z.string(),
21609
- description: zod.z.string()
22371
+ var ProbeTargetConfigResultSchema = zod.z.object({
22372
+ ok: zod.z.boolean(),
22373
+ resolvedUrl: zod.z.string(),
22374
+ httpStatus: zod.z.number().int().optional(),
22375
+ stateInstance: zod.z.string().optional(),
22376
+ error: zod.z.string().optional()
22377
+ });
22378
+ method(zod.z.object({}), zod.z.array(TargetKindSchema)), method(zod.z.object({}), zod.z.array(TargetSchema), { auth: "admin" }), method(zod.z.object({ targetId: zod.z.string() }), TargetSchema, { auth: "admin" }), method(zod.z.object({
22379
+ kind: zod.z.string(),
22380
+ config: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
22381
+ /**
22382
+ * When editing / duplicating, the blank password field never round-trips
22383
+ * the stored token. Pass the source target id so the provider can restore
22384
+ * secrets server-side before dialling (same merge as upsert).
22385
+ */
22386
+ targetId: zod.z.string().optional()
22387
+ }), zod.z.array(DiscoveredTargetSchema), { auth: "admin" }), method(zod.z.object({
22388
+ kind: zod.z.string(),
22389
+ config: zod.z.record(zod.z.string(), zod.z.unknown()),
22390
+ /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
22391
+ targetId: zod.z.string().optional()
22392
+ }), ProbeTargetConfigResultSchema, {
22393
+ kind: "mutation",
22394
+ auth: "admin"
22395
+ }), method(zod.z.object({
22396
+ targetId: zod.z.string(),
22397
+ notification: NotificationSchema
22398
+ }), SendResultSchema, {
22399
+ kind: "mutation",
22400
+ auth: "admin"
22401
+ }), method(zod.z.object({
22402
+ targetId: zod.z.string(),
22403
+ sample: NotificationSchema.optional()
22404
+ }), TestResultSchema, {
22405
+ kind: "mutation",
22406
+ auth: "admin"
22407
+ }), method(zod.z.object({
22408
+ target: TargetSchema,
22409
+ /**
22410
+ * Duplicate flow: create a NEW target id while copying secrets from an
22411
+ * existing row. The UI never holds the stored token; blank / redacted
22412
+ * password fields are restored from this source (D634 still applies if
22413
+ * destinations diverge without a re-typed secret).
22414
+ */
22415
+ cloneSecretsFrom: zod.z.string().optional()
22416
+ }), TargetSchema, {
22417
+ kind: "mutation",
22418
+ auth: "admin"
22419
+ }), method(zod.z.object({ targetId: zod.z.string() }), zod.z.void(), {
22420
+ kind: "mutation",
22421
+ auth: "admin"
22422
+ }), method(zod.z.object({
22423
+ targetId: zod.z.string(),
22424
+ enabled: zod.z.boolean()
22425
+ }), zod.z.void(), {
22426
+ kind: "mutation",
22427
+ auth: "admin"
21610
22428
  });
21611
22429
  //#endregion
21612
22430
  //#region src/catalogs/audio-classmap.ts
@@ -23123,13 +23941,28 @@ var NcRuleActionSchema = zod.z.discriminatedUnion("kind", [zod.z.object({
23123
23941
  seconds: zod.z.number().min(0).max(300)
23124
23942
  }), zod.z.object({
23125
23943
  kind: zod.z.literal("cap"),
23126
- deviceId: zod.z.number().int(),
23944
+ /**
23945
+ * WHO the step acts on (D983) — the only shape that is written. A sequence
23946
+ * stored before D983 carries `deviceId`; the sequence store reads it as a
23947
+ * `devices` target.
23948
+ */
23949
+ target: ActionTargetSchema.optional(),
23950
+ /** The vocabulary action the step performs; required for a `domain` target. */
23951
+ actionId: zod.z.string().min(1).optional(),
23952
+ /** @deprecated The pre-D983 single device. Read, never written. */
23953
+ deviceId: zod.z.number().int().optional(),
23127
23954
  /** Capability name, e.g. `alarm-panel`. */
23128
23955
  cap: zod.z.string().min(1),
23129
23956
  /** Method on it. The executor refuses a non-device-scoped cap. */
23130
23957
  method: zod.z.string().min(1),
23131
23958
  /** Method arguments, minus `deviceId` (the executor injects it). */
23132
23959
  args: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
23960
+ }).refine((step) => step.target !== void 0 || step.deviceId !== void 0, {
23961
+ message: "a cap step needs a target",
23962
+ path: ["target"]
23963
+ }).refine((step) => step.target?.kind !== "domain" || step.actionId !== void 0, {
23964
+ message: "a domain target needs the vocabulary action it applies to (actionId)",
23965
+ path: ["actionId"]
23133
23966
  })]);
23134
23967
  /**
23135
23968
  * A named, ordered run of steps shared across rules, with its own throttle.