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