@camstack/addon-export-hap 1.2.9 → 1.2.10

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.
@@ -10,7 +10,7 @@ import { networkInterfaces } from "node:os";
10
10
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
11
11
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
12
12
  //#endregion
13
- //#region ../types/dist/event-category-Bz24uP1U.mjs
13
+ //#region ../types/dist/event-category-41fKf-q9.mjs
14
14
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
15
15
  EventCategory["SystemBoot"] = "system.boot";
16
16
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -70,6 +70,26 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
70
70
  */
71
71
  EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
72
72
  /**
73
+ * A liveness invariant of this node is false — it has no devices, every
74
+ * camera is offline, or a camera that was recording has stopped producing
75
+ * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
76
+ * `findingId` is stable across ticks), only after its boot grace period.
77
+ * AlertCenter raises a persistent operator-visible alert.
78
+ *
79
+ * It exists because on 2026-08-03 the hub ran four hours with zero devices
80
+ * and zero recordings while every surface stayed quiet.
81
+ *
82
+ * Payload: `{ findingId, severity, title, message, deviceId? }`.
83
+ */
84
+ EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
85
+ /**
86
+ * A previously reported liveness fault is true again. AlertCenter dismisses
87
+ * the matching `SystemLivenessFailed` alert.
88
+ *
89
+ * Payload: `{ findingId }`.
90
+ */
91
+ EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
92
+ /**
73
93
  * Monitor is attempting to reload a failed addon NOW. UI uses this
74
94
  * to show a spinner during the retry attempt. Same transient nature
75
95
  * as AddonRetryScheduled.
@@ -7320,14 +7340,7 @@ var RecordingConfigSchema = object({
7320
7340
  * windows only — existing sheets are immutable, and each window's index
7321
7341
  * carries its own tile dims so mixed-preset history renders correctly.
7322
7342
  */
7323
- scrubThumbnails: ScrubThumbnailPresetSchema.optional(),
7324
- /**
7325
- * OPT-IN thumbnail-strip generation for this camera: every keyframe of the
7326
- * low recording saved as a JPEG (the fast-drag scrub depth), a derived
7327
- * cache that eviction reclaims with the footage. Absent/false = no strips
7328
- * are written and scrub reads exact keyframes at every velocity.
7329
- */
7330
- stripsEnabled: boolean().optional()
7343
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7331
7344
  }).strict();
7332
7345
  /**
7333
7346
  * Ops-log — the durable, append-only operations audit shared by the
@@ -7389,7 +7402,7 @@ var OpsLogQueryInputSchema = object({
7389
7402
  /**
7390
7403
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7391
7404
  *
7392
- * One shape shared by the recorder's `relocateFootage` (segments + strips) and
7405
+ * One shape shared by the recorder's `relocateFootage` (segments) and
7393
7406
  * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7394
7407
  * page renders both movers with one component. Jobs are in-RAM (a restart
7395
7408
  * forgets them — re-running is safe by construction: copy-if-absent, delete
@@ -7411,7 +7424,7 @@ var RelocateJobSchema = object({
7411
7424
  toLocationId: string(),
7412
7425
  /** Scoped device, or null = every device. */
7413
7426
  deviceId: number().nullable(),
7414
- /** What the job moves (owner-addon specific: segments/strips or media). */
7427
+ /** What the job moves (owner-addon specific: segments or media). */
7415
7428
  entities: array(string()),
7416
7429
  filesMoved: number().int(),
7417
7430
  bytesMoved: number().int(),
@@ -7425,7 +7438,7 @@ var RelocateFootageInputSchema = object({
7425
7438
  deviceId: number().optional(),
7426
7439
  fromLocationId: string(),
7427
7440
  toLocationId: string(),
7428
- entities: array(_enum(["segments", "strips"])).optional(),
7441
+ entities: array(_enum(["segments"])).optional(),
7429
7442
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7430
7443
  * never allowed to starve live writers. */
7431
7444
  throttleMbps: number().min(1).max(1e3).optional()
@@ -8976,6 +8989,72 @@ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, Accessor
8976
8989
  DeviceFeature.BatteryOperated;
8977
8990
  new Set(["devices", "classes"]);
8978
8991
  /**
8992
+ * Alarm-panel cap. Models HA `alarm_control_panel.*` on
8993
+ * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
8994
+ * across disarmed / armed_(home|away|night|vacation|custom_bypass) /
8995
+ * arming / pending / triggered / disarming.
8996
+ *
8997
+ * Many panels require a PIN code on arm / disarm — the optional
8998
+ * `code` field on the methods passes it through to the upstream
8999
+ * service; it's NEVER persisted in the runtime slice or any event
9000
+ * payload. The presence of a required code is signalled by
9001
+ * `DeviceFeature.AlarmPinRequired` so the UI gates a code-entry
9002
+ * field without a slice fetch.
9003
+ *
9004
+ * `availableModes` mirrors HA's `supported_features`-derived arm
9005
+ * mode list — the UI renders only the buttons the panel accepts.
9006
+ */
9007
+ var AlarmStateSchema = _enum([
9008
+ "disarmed",
9009
+ "armed_home",
9010
+ "armed_away",
9011
+ "armed_night",
9012
+ "armed_vacation",
9013
+ "armed_custom_bypass",
9014
+ "arming",
9015
+ "disarming",
9016
+ "pending",
9017
+ "triggered"
9018
+ ]);
9019
+ var AlarmArmModeSchema = _enum([
9020
+ "home",
9021
+ "away",
9022
+ "night",
9023
+ "vacation",
9024
+ "custom_bypass"
9025
+ ]);
9026
+ object({
9027
+ /** Current lifecycle state. */
9028
+ state: AlarmStateSchema,
9029
+ /** Subset of arm modes the panel accepts. UI renders one button per
9030
+ * mode in this list. */
9031
+ availableModes: array(AlarmArmModeSchema),
9032
+ /** Whether the panel requires a PIN on arm / disarm. Mirrors
9033
+ * `DeviceFeature.AlarmPinRequired` for slice consumers. */
9034
+ requiresCode: boolean(),
9035
+ /** Ms epoch when the slice was last updated. */
9036
+ lastChangedAt: number()
9037
+ });
9038
+ DeviceType.AlarmPanel, method(object({
9039
+ deviceId: number().int().nonnegative(),
9040
+ mode: AlarmArmModeSchema,
9041
+ /** Optional PIN code. Required when `requiresCode === true`.
9042
+ * Passed through to the upstream service; never persisted. */
9043
+ code: string().min(1).optional()
9044
+ }), _void(), {
9045
+ kind: "mutation",
9046
+ auth: "admin"
9047
+ }), method(object({
9048
+ deviceId: number().int().nonnegative(),
9049
+ code: string().min(1).optional()
9050
+ }), _void(), {
9051
+ kind: "mutation",
9052
+ auth: "admin"
9053
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
9054
+ kind: "mutation",
9055
+ auth: "admin"
9056
+ });
9057
+ /**
8979
9058
  * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
8980
9059
  * motion-zones, and the detection zones/lines editor all speak this one
8981
9060
  * language so a single drawing-plane editor and the providers stay
@@ -9037,6 +9116,276 @@ var MaskGridDimsSchema = object({
9037
9116
  height: number()
9038
9117
  });
9039
9118
  /**
9119
+ * notification-output — canonical, capability-gated notification delivery.
9120
+ *
9121
+ * Apprise-derived model (see
9122
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
9123
+ * callers emit ONE canonical `Notification`; each provider declares a
9124
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
9125
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
9126
+ * message to what the kind supports — callers never special-case a service.
9127
+ *
9128
+ * DESIGN DECISIONS (locked):
9129
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
9130
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
9131
+ * cap. Rationale: the admin UI needs one uniform surface across the
9132
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
9133
+ * alternative would fork the UI per addon and cannot host the
9134
+ * discovery→adopt flow.
9135
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
9136
+ * the generated cap-mount auto-`concatCollection`-fans them across every
9137
+ * registered provider (notifiers addon + HA addon) so one catalog is
9138
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
9139
+ * `addonId` the generated collection router extracts from the call input.
9140
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
9141
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
9142
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
9143
+ * base64 fallback needed.
9144
+ *
9145
+ * TODO (deferred, closed-set change — separate decision): add
9146
+ * `providerKind: 'notify'` so notification providers surface on the unified
9147
+ * admin "Integrations" page.
9148
+ */
9149
+ /**
9150
+ * Zentik-derived typed-media enum — the superset across every kind. Each
9151
+ * adapter picks what it supports and the degrade engine filters the rest.
9152
+ */
9153
+ var AttachmentMediaTypeSchema = _enum([
9154
+ "image",
9155
+ "video",
9156
+ "gif",
9157
+ "audio",
9158
+ "icon"
9159
+ ]);
9160
+ /**
9161
+ * A single attachment. Exactly one of `url` (remote source, most adapters
9162
+ * prefer this) or `bytes` (inline source; required for Pushover-style
9163
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
9164
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
9165
+ */
9166
+ var AttachmentSchema = object({
9167
+ mediaType: AttachmentMediaTypeSchema,
9168
+ url: string().optional(),
9169
+ bytes: _instanceof(Uint8Array).optional(),
9170
+ mime: string().optional(),
9171
+ name: string().optional()
9172
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
9173
+ var NotificationFormatSchema = _enum([
9174
+ "text",
9175
+ "markdown",
9176
+ "html"
9177
+ ]);
9178
+ /**
9179
+ * The CLOSED icon vocabulary an action button may use.
9180
+ *
9181
+ * A closed set, not a free string, and that is the whole point: an arbitrary
9182
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
9183
+ * notices — the same class of gap as a zone vocabulary nothing produced
9184
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
9185
+ * declares `actionIcons: false` and the degrade engine strips the field.
9186
+ *
9187
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
9188
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
9189
+ * differently.
9190
+ */
9191
+ var NotificationActionIconSchema = _enum([
9192
+ "acknowledge",
9193
+ "dismiss",
9194
+ "silence",
9195
+ "view",
9196
+ "play",
9197
+ "open",
9198
+ "close",
9199
+ "lock",
9200
+ "unlock",
9201
+ "arm",
9202
+ "disarm",
9203
+ "light",
9204
+ "alert"
9205
+ ]);
9206
+ /** A single tap-through action button. */
9207
+ var NotificationActionSchema = object({
9208
+ id: string(),
9209
+ label: string(),
9210
+ url: string().optional(),
9211
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
9212
+ icon: NotificationActionIconSchema.optional(),
9213
+ /**
9214
+ * Renders in a warning style where the notifier supports it.
9215
+ *
9216
+ * A HINT, never a gate. The callback's authority is its token and nothing
9217
+ * else — see `notification-center/action-token.ts` for what that does and
9218
+ * does not buy.
9219
+ */
9220
+ destructive: boolean().optional()
9221
+ });
9222
+ /**
9223
+ * The canonical notification. `body` is the only hard field (Apprise model).
9224
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
9225
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
9226
+ * the adapter maps this ordinal onto its native level. `level?` is an
9227
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
9228
+ * `priority` for that one target.
9229
+ */
9230
+ var NotificationSchema = object({
9231
+ body: string(),
9232
+ title: string().optional(),
9233
+ format: NotificationFormatSchema.default("text"),
9234
+ priority: number().int().min(1).max(5).default(3),
9235
+ level: string().optional(),
9236
+ attachments: array(AttachmentSchema).optional(),
9237
+ clickUrl: string().optional(),
9238
+ actions: array(NotificationActionSchema).optional(),
9239
+ sound: string().optional(),
9240
+ ttl: number().optional(),
9241
+ tag: string().optional(),
9242
+ deviceId: number().optional(),
9243
+ eventId: string().optional(),
9244
+ metadata: record(string(), unknown()).optional()
9245
+ });
9246
+ /** One declared native severity/priority level for a kind. */
9247
+ var TargetKindLevelSchema = object({
9248
+ id: string(),
9249
+ label: string(),
9250
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
9251
+ ordinal: number().int().min(1).max(5).nullable(),
9252
+ flags: object({
9253
+ critical: boolean().optional(),
9254
+ silent: boolean().optional(),
9255
+ noPush: boolean().optional()
9256
+ }).optional(),
9257
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
9258
+ requires: array(string()).optional(),
9259
+ description: string().optional()
9260
+ });
9261
+ /** The full capability block consulted before dispatch. */
9262
+ var TargetKindCapsSchema = object({
9263
+ attachments: object({
9264
+ mediaTypes: array(AttachmentMediaTypeSchema),
9265
+ mode: _enum([
9266
+ "url",
9267
+ "bytes",
9268
+ "both"
9269
+ ]),
9270
+ max: number().int().nonnegative(),
9271
+ maxBytes: number().int().positive().optional()
9272
+ }),
9273
+ /** Max action buttons (0 = none). */
9274
+ actions: number().int().nonnegative(),
9275
+ /**
9276
+ * Whether this kind renders a per-action ICON.
9277
+ *
9278
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
9279
+ * run on the addon cap path — three production failures in one day taught
9280
+ * this repo that once. Absent is read as false by the degrade engine, which
9281
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
9282
+ * assumed and dropped costs the operator's trust in the field.
9283
+ */
9284
+ actionIcons: boolean().optional(),
9285
+ levels: array(TargetKindLevelSchema),
9286
+ format: array(NotificationFormatSchema),
9287
+ clickUrl: boolean(),
9288
+ sound: boolean(),
9289
+ ttl: boolean(),
9290
+ bodyMaxLen: number().int().positive()
9291
+ });
9292
+ /**
9293
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
9294
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
9295
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
9296
+ * the union is large and not meant for runtime validation here; the exported
9297
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
9298
+ */
9299
+ var ConfigSchemaPassthrough$1 = unknown();
9300
+ var TargetKindSchema = object({
9301
+ kind: string(),
9302
+ label: string(),
9303
+ icon: string(),
9304
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
9305
+ addonId: string(),
9306
+ /**
9307
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
9308
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
9309
+ * when the addon bundles no icon for that kind — the client then falls back
9310
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
9311
+ *
9312
+ * Root-relative on purpose: it resolves against whatever origin serves a web
9313
+ * client, and a native client joins it onto its own hub base.
9314
+ *
9315
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
9316
+ * field that survived only because the runtime cap-router forwards provider
9317
+ * output verbatim — so every consumer had to re-declare it by hand to stop
9318
+ * its own Zod parse from stripping it, and the whole arrangement would have
9319
+ * broken silently the moment output validation was tightened anywhere.
9320
+ */
9321
+ iconUrl: string().optional(),
9322
+ /**
9323
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
9324
+ *
9325
+ * The server knows this and therefore says it, because the client cannot
9326
+ * safely guess: a React-Native client renders SVG and raster through two
9327
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
9328
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
9329
+ * placeholder glyph for every vector icon while the web build looked fine.
9330
+ *
9331
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
9332
+ * not been updated — a client that cannot determine the type should prefer
9333
+ * its raster path, which is the safe default for an unknown image.
9334
+ */
9335
+ iconMediaType: string().optional(),
9336
+ configSchema: ConfigSchemaPassthrough$1,
9337
+ supportsDiscovery: boolean(),
9338
+ caps: TargetKindCapsSchema
9339
+ });
9340
+ /**
9341
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
9342
+ * (return a presence marker only) when serving `listTargets` — never
9343
+ * round-trip a stored secret to the UI.
9344
+ */
9345
+ var TargetSchema = object({
9346
+ id: string(),
9347
+ name: string(),
9348
+ kind: string(),
9349
+ addonId: string(),
9350
+ enabled: boolean(),
9351
+ config: record(string(), unknown())
9352
+ });
9353
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
9354
+ var DiscoveredTargetSchema = object({
9355
+ kind: string(),
9356
+ suggestedName: string(),
9357
+ config: record(string(), unknown())
9358
+ });
9359
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
9360
+ var RenderedAsSchema = object({
9361
+ level: string(),
9362
+ format: NotificationFormatSchema,
9363
+ attachmentsSent: number().int().nonnegative(),
9364
+ actionsSent: number().int().nonnegative(),
9365
+ truncated: boolean(),
9366
+ dropped: array(string())
9367
+ });
9368
+ var SendResultSchema = object({
9369
+ success: boolean(),
9370
+ error: string().optional(),
9371
+ renderedAs: RenderedAsSchema.optional()
9372
+ });
9373
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
9374
+ var TestResultSchema = SendResultSchema;
9375
+ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
9376
+ kind: string(),
9377
+ config: record(string(), unknown()).optional()
9378
+ }), array(DiscoveredTargetSchema)), method(object({
9379
+ targetId: string(),
9380
+ notification: NotificationSchema
9381
+ }), SendResultSchema, { kind: "mutation" }), method(object({
9382
+ targetId: string(),
9383
+ sample: NotificationSchema.optional()
9384
+ }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
9385
+ targetId: string(),
9386
+ enabled: boolean()
9387
+ }), _void(), { kind: "mutation" });
9388
+ /**
9040
9389
  * notification-rules — the Notification Center rule surface (P1 core).
9041
9390
  *
9042
9391
  * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
@@ -9166,7 +9515,115 @@ var NcZoneConditionSchema = object({
9166
9515
  * The P1 condition set — a flat AND of groups; absent group = pass;
9167
9516
  * membership lists are OR within the list (spec §2.3).
9168
9517
  */
9518
+ /**
9519
+ * What a rule may actuate.
9520
+ *
9521
+ * **No hand-maintained allowlist** (operator decision, and the right one — a
9522
+ * written list of methods is a third parallel map to keep aligned, and this
9523
+ * repo has paid for those). The boundary instead comes from a property the
9524
+ * capabilities already carry: an action may target only a **device-scoped**
9525
+ * capability method.
9526
+ *
9527
+ * That is not decoration. A rule can be authored by a NON-ADMIN — personal
9528
+ * rules are a supported flow — and the executor runs with the addon's
9529
+ * privileges, so an unbounded action is an arbitrary RPC channel with a
9530
+ * privilege escalation attached. Restricting to device scope excludes the
9531
+ * system caps (`device-manager.removeDevice` and friends) by construction,
9532
+ * costs nothing to maintain, and cannot rot: a cap that stops being
9533
+ * device-scoped stops being actuatable in the same change.
9534
+ *
9535
+ * The executor enforces it; {@link NcRuleActionSchema} carries the intent.
9536
+ */
9537
+ /**
9538
+ * One step of a sequence.
9539
+ *
9540
+ * `wait` is a first-class step rather than a property of the next action: it is
9541
+ * what makes a sequence a SEQUENCE and not a list — "unlock, wait 5s, open"
9542
+ * cannot be expressed otherwise.
9543
+ */
9544
+ var NcRuleActionSchema = discriminatedUnion("kind", [object({
9545
+ kind: literal("wait"),
9546
+ seconds: number().min(0).max(300)
9547
+ }), object({
9548
+ kind: literal("cap"),
9549
+ deviceId: number().int(),
9550
+ /** Capability name, e.g. `alarm-panel`. */
9551
+ cap: string().min(1),
9552
+ /** Method on it. The executor refuses a non-device-scoped cap. */
9553
+ method: string().min(1),
9554
+ /** Method arguments, minus `deviceId` (the executor injects it). */
9555
+ args: record(string(), unknown()).optional()
9556
+ })]);
9557
+ /**
9558
+ * A named, ordered run of steps with its own throttle.
9559
+ *
9560
+ * `minDelaySec` exists because a noisy rule otherwise hammers a physical
9561
+ * actuator — the rule's own cooldown governs NOTIFICATIONS, which is a
9562
+ * different budget from "how often may this gate actually open".
9563
+ */
9564
+ var NcRuleActionSequenceSchema = object({
9565
+ name: string().min(1).max(120),
9566
+ enabled: boolean(),
9567
+ minDelaySec: number().int().min(0).max(86400).optional(),
9568
+ actions: array(NcRuleActionSchema).min(1)
9569
+ });
9570
+ /**
9571
+ * One button carried by the notification, running a named sequence on tap.
9572
+ *
9573
+ * **Read this before adding a button that does something physical.** The tap
9574
+ * arrives over a link that travelled through third-party infrastructure — ntfy,
9575
+ * a push relay, whatever forwarded the message — and the callback's ONLY
9576
+ * authority is the token in that link: single-use, short-lived, bound to this
9577
+ * one action of this one notification. It does not identify who tapped.
9578
+ * Whoever holds the notification can run the button, once, inside the window.
9579
+ * That is the operator's explicit choice (2026-08-05), and `destructive` is a
9580
+ * rendering hint, not a second gate. [D47](decisions/adr-0047.md).
9581
+ */
9582
+ var NcRuleNotificationButtonSchema = object({
9583
+ /** Stable id — travels in the callback and identifies the button in logs. */
9584
+ id: string().min(1).max(64),
9585
+ label: string().min(1).max(40),
9586
+ /** Name of a sequence in `onTrigger`. The dispatcher drops a button whose
9587
+ * sequence does not exist rather than minting a token for nothing. */
9588
+ sequence: string().min(1).max(120),
9589
+ icon: NotificationActionIconSchema.optional(),
9590
+ destructive: boolean().optional()
9591
+ });
9592
+ /**
9593
+ * Sequences a rule runs, by hook point.
9594
+ *
9595
+ * ONLY `onTrigger` is here, deliberately. The reference also has activation /
9596
+ * deactivation / reset / post-generation hooks, and they are wanted — but this
9597
+ * repo's expensive failure mode is declaring a surface nothing produces, so a
9598
+ * hook appears here in the same change that produces its edge, never before.
9599
+ */
9600
+ var NcRuleActionsSchema = object({
9601
+ /** Runs when the rule MATCHES. */
9602
+ onTrigger: array(NcRuleActionSequenceSchema).optional(),
9603
+ /**
9604
+ * Buttons the NOTIFICATION carries, each running one of this rule's
9605
+ * sequences when tapped.
9606
+ *
9607
+ * Deliberately a REFERENCE to a sequence rather than a second place to
9608
+ * author steps. A button that could define its own actions would be a
9609
+ * parallel actuation vocabulary — the executor's device-scope check, the
9610
+ * stop-at-first-failure rule and the per-sequence throttle all live on
9611
+ * sequences, and a second authoring surface would drift from every one of
9612
+ * them.
9613
+ *
9614
+ * A sequence reachable ONLY by a button simply appears in `onTrigger` with
9615
+ * `enabled: false`: it is then authored, throttled and validated like the
9616
+ * rest, and nothing runs it automatically.
9617
+ */
9618
+ buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
9619
+ });
9169
9620
  var NcConditionsSchema = object({
9621
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
9622
+ deviceState: object({
9623
+ deviceId: number().int(),
9624
+ /** Any of these matches. */
9625
+ states: array(string().min(1)).min(1)
9626
+ }).optional(),
9170
9627
  /** Device scope — absent = all devices. */
9171
9628
  devices: array(number()).optional(),
9172
9629
  /** Detector class names (any overlap with the record's class set). */
@@ -9367,6 +9824,14 @@ var NcMediaPolicySchema = object({
9367
9824
  clipPreRollSec: number().int().min(0).max(30).optional(),
9368
9825
  clipPostRollSec: number().int().min(0).max(30).optional(),
9369
9826
  /**
9827
+ * Playback rate of the attached gif / clip. Absent = 2x.
9828
+ *
9829
+ * A notification clip is GLANCED at on a lock screen, not watched: at real
9830
+ * time an eight-second passage is eight seconds of the recipient's attention
9831
+ * and twice the bytes. 1 is real time for the operator who wants it.
9832
+ */
9833
+ clipSpeed: number().min(1).max(8).optional(),
9834
+ /**
9370
9835
  * Which stream profile the footage is cut from. Absent = the CHEAPEST
9371
9836
  * assigned profile: a notification is watched on a phone, so the 4K
9372
9837
  * rendition would burn CPU to produce a file the client downscales anyway.
@@ -9438,7 +9903,30 @@ var NcRuleInputSchema = object({
9438
9903
  * behaviour, visible to all, read-only in the viewer). Present = personal
9439
9904
  * rule owned by this userId. Server-stamped; never trusted from a client.
9440
9905
  */
9441
- ownerUserId: string().optional()
9906
+ ownerUserId: string().optional(),
9907
+ /**
9908
+ * May a non-admin snooze this rule for EVERYONE, not just themselves?
9909
+ *
9910
+ * A snooze is personal by default — it silences the person who set it. This
9911
+ * opts THIS rule into the "the gardener is here all afternoon" case, where
9912
+ * silencing the camera for the whole household is legitimate. It silences
9913
+ * other people, so it is off unless a rule deliberately allows it.
9914
+ *
9915
+ * `.optional()`, deliberately NOT `.default()`: a Zod default does not run on
9916
+ * the addon cap path (three production failures in one day), so absent is
9917
+ * read as `false` by {@link canSetGlobal} in the engine. Admins are not bound
9918
+ * by this flag — see the scope rules on that function.
9919
+ */
9920
+ snoozeAllowGlobal: boolean().optional(),
9921
+ /**
9922
+ * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
9923
+ *
9924
+ * This is what makes the rule set the alarm's trigger set without the alarm
9925
+ * being a special case: arming is
9926
+ * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
9927
+ * shape as every other actuation.
9928
+ */
9929
+ actions: NcRuleActionsSchema.optional()
9442
9930
  });
9443
9931
  /**
9444
9932
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -9509,7 +9997,8 @@ var NcConditionDescriptorSchema = object({
9509
9997
  "packagePhase",
9510
9998
  "crossingSelect",
9511
9999
  "polygonDraw",
9512
- "occupancy"
10000
+ "occupancy",
10001
+ "deviceState"
9513
10002
  ]),
9514
10003
  operator: _enum([
9515
10004
  "in",
@@ -9523,7 +10012,28 @@ var NcConditionDescriptorSchema = object({
9523
10012
  /** Which delivery kinds the condition applies to. */
9524
10013
  appliesTo: array(NcDeliverySchema),
9525
10014
  phase: string(),
9526
- description: string().optional()
10015
+ description: string().optional(),
10016
+ /**
10017
+ * The CHOICES for a single-choice widget (`sourceSelect`, `crossingSelect`,
10018
+ * `packagePhase`, …), served with the descriptor.
10019
+ *
10020
+ * Before this the descriptor said which widget to render and not what to put
10021
+ * in it, so every option list lived in three places: this file's enums, the
10022
+ * admin's `NC_*_OPTIONS` and the viewer's `NC_*_VALUES`. That triple mirror
10023
+ * is the drift that emptied the viewer's rule editor on 2026-08-04 — the app
10024
+ * mirrors the cap by hand, so it is only ever as current as its last build.
10025
+ *
10026
+ * With the options on the wire, a condition of an EXISTING `valueType` costs
10027
+ * zero client changes. Clients keep a local fallback for an older hub that
10028
+ * does not send them; absent here is "use your own list", not "no choices".
10029
+ */
10030
+ options: array(object({
10031
+ /** Written to the rule verbatim. `''` means the ABSENT state. */
10032
+ value: string(),
10033
+ label: string(),
10034
+ /** What THIS choice matches — shown one at a time, under the control. */
10035
+ hint: string().optional()
10036
+ })).readonly().optional()
9527
10037
  });
9528
10038
  /**
9529
10039
  * The delivery lifecycle status of a history row — a straight read of the
@@ -9608,6 +10118,135 @@ var NcHistoryFilterSchema = object({
9608
10118
  until: number().optional(),
9609
10119
  limit: number().int().min(1).max(500).default(100)
9610
10120
  });
10121
+ /**
10122
+ * What a snooze covers. Broader scopes win when several overlap, so one window
10123
+ * leaves ONE digest rather than a rule snooze and a whole-feed snooze both
10124
+ * summarising the same silence.
10125
+ */
10126
+ var NcSnoozeScopeSchema = _enum([
10127
+ "rule",
10128
+ "device",
10129
+ "all"
10130
+ ]);
10131
+ /**
10132
+ * Client-authored snooze. The server stamps `userId`, `startedAt` and
10133
+ * `expiresAt` — a DURATION is sent rather than an instant so a client with a
10134
+ * skewed clock cannot author a window that is already over, or never ends.
10135
+ */
10136
+ var NcSnoozeInputSchema = object({
10137
+ scope: NcSnoozeScopeSchema,
10138
+ /** Required when `scope: 'rule'` — a scoped snooze with no id matches
10139
+ * NOTHING rather than degrading to "everything". */
10140
+ ruleId: string().optional(),
10141
+ /** Required when `scope: 'device'`. */
10142
+ deviceId: number().int().optional(),
10143
+ durationMinutes: number().int().min(1).max(1440),
10144
+ /**
10145
+ * Silence this for EVERY recipient, not just the caller. Permission is
10146
+ * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
10147
+ * broader scopes). Absent = personal.
10148
+ */
10149
+ global: boolean().optional(),
10150
+ /**
10151
+ * Deliver a summary of what was suppressed when the window ends. Absent =
10152
+ * ON: someone silencing a nuisance camera wants it off, someone silencing a
10153
+ * SECURITY camera wants to know what they missed, and choosing "off" for
10154
+ * everybody is how a snooze becomes an outage. Resolved to a concrete
10155
+ * boolean by the server at create time — never left to a Zod default, which
10156
+ * does not run on the addon cap path.
10157
+ */
10158
+ summary: boolean().optional()
10159
+ });
10160
+ /** A persisted snooze window. */
10161
+ var NcSnoozeSchema = object({
10162
+ id: string(),
10163
+ /** Who set it. Also who it silences, unless `global`. */
10164
+ userId: string(),
10165
+ scope: NcSnoozeScopeSchema,
10166
+ ruleId: string().optional(),
10167
+ deviceId: number().int().optional(),
10168
+ startedAt: number(),
10169
+ /** Exclusive: at exactly this instant the snooze is over. Expiry is a
10170
+ * COMPARISON, not a job — no sweeper can leave the operator silenced. */
10171
+ expiresAt: number(),
10172
+ global: boolean(),
10173
+ summary: boolean(),
10174
+ /** When the end-of-window digest went out. Absent = not sent (yet, or the
10175
+ * window has not closed, or `summary` is false). */
10176
+ digestSentAt: number().optional()
10177
+ });
10178
+ object({
10179
+ snoozeId: string(),
10180
+ targetId: string(),
10181
+ ruleId: string(),
10182
+ ruleName: string(),
10183
+ deviceId: number().int(),
10184
+ /** How many notifications this snooze hid for that pair. */
10185
+ count: number().int(),
10186
+ firstAt: number(),
10187
+ lastAt: number()
10188
+ });
10189
+ /**
10190
+ * The three durations the panel's state machine runs on, plus who hears about
10191
+ * an arm.
10192
+ *
10193
+ * They live on the NOTIFICATION-RULES cap, not on `alarm-panel`, on purpose:
10194
+ * `alarm-panel` is `deviceNative` and its other provider mirrors somebody
10195
+ * else's panel, which has its own delays and no way to be told these. This is
10196
+ * configuration of the panel CamStack owns, and the Notification Center owns
10197
+ * that panel.
10198
+ */
10199
+ var NcAlarmSettingsSchema = object({
10200
+ /** Grace period between arming and the mode taking effect. 0 = immediate. */
10201
+ exitDelaySec: number().int().min(0).max(600),
10202
+ /** Grace period between a trigger and the alarm firing. 0 = immediate. */
10203
+ entryDelaySec: number().int().min(0).max(600),
10204
+ /**
10205
+ * How long `triggered` lasts before the panel re-arms itself.
10206
+ * **0 = until an operator disarms it** — the behaviour before this field
10207
+ * existed, and therefore what an untouched install keeps doing.
10208
+ */
10209
+ triggeredDurationSec: number().int().min(0).max(3600),
10210
+ /** Send a notification when a mode takes effect. */
10211
+ announceArm: boolean(),
10212
+ /**
10213
+ * Where that notification goes. Target ids from `notification-output`.
10214
+ *
10215
+ * Explicit rather than "everyone": an arm announcement is a household
10216
+ * message, and broadcasting it to every configured endpoint (including a
10217
+ * webhook wired to something else) is not a default anybody would choose.
10218
+ * Empty with `announceArm: true` sends nothing, and the server logs that —
10219
+ * silence must be attributable.
10220
+ */
10221
+ announceTargets: array(string().min(1)).max(16)
10222
+ });
10223
+ /** Every field optional — a tab edits one control at a time. */
10224
+ var NcAlarmSettingsPatchSchema = NcAlarmSettingsSchema.partial();
10225
+ /**
10226
+ * What one arm mode actually arms, DERIVED from the enabled rules gated on it.
10227
+ * Never authored, never stored — see `alarm-mode-coverage.ts` for why a stored
10228
+ * copy would lie the first time a rule is disabled.
10229
+ */
10230
+ var NcAlarmModeCoverageSchema = object({
10231
+ mode: AlarmArmModeSchema,
10232
+ /** Enabled rules gated on `armed_<mode>`. Zero means the mode does nothing. */
10233
+ ruleCount: number().int().min(0),
10234
+ /** At least one covering rule has no device scope, so the mode covers all. */
10235
+ allDevices: boolean(),
10236
+ /** Ids named by the covering rules. A SUBSET when `allDevices` is true. */
10237
+ deviceIds: array(number().int())
10238
+ });
10239
+ var NcAlarmConfigSchema = object({
10240
+ /**
10241
+ * The panel's device id, or null when this install has no panel (the ensure
10242
+ * step failed, or this is not the hub). Null is the honest answer: an editor
10243
+ * that rendered delays for a panel that does not exist would be a form whose
10244
+ * Save does nothing.
10245
+ */
10246
+ deviceId: number().int().nullable(),
10247
+ settings: NcAlarmSettingsSchema,
10248
+ coverage: array(NcAlarmModeCoverageSchema)
10249
+ });
9611
10250
  method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), method(object({ ruleId: string() }), object({ rule: NcRuleSchema.nullable() }), { auth: "admin" }), method(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
9612
10251
  kind: "mutation",
9613
10252
  auth: "admin",
@@ -9637,7 +10276,16 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
9637
10276
  }), method(object({}), object({
9638
10277
  catalog: array(NcConditionDescriptorSchema),
9639
10278
  taxonomy: NcTaxonomySchema.optional()
9640
- })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
10279
+ })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" }), method(object({}), object({ snoozes: array(NcSnoozeSchema) }), { caller: "required" }), method(object({ snooze: NcSnoozeInputSchema }), object({ snooze: NcSnoozeSchema }), {
10280
+ kind: "mutation",
10281
+ caller: "required"
10282
+ }), method(object({ snoozeId: string() }), object({ success: literal(true) }), {
10283
+ kind: "mutation",
10284
+ caller: "required"
10285
+ }), method(object({}), NcAlarmConfigSchema, { auth: "admin" }), method(object({ patch: NcAlarmSettingsPatchSchema }), NcAlarmConfigSchema, {
10286
+ kind: "mutation",
10287
+ auth: "admin"
10288
+ });
9641
10289
  /**
9642
10290
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
9643
10291
  *
@@ -9845,72 +10493,6 @@ object({
9845
10493
  precision: number().int().min(0).max(10).optional()
9846
10494
  });
9847
10495
  DeviceType.Sensor;
9848
- /**
9849
- * Alarm-panel cap. Models HA `alarm_control_panel.*` on
9850
- * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
9851
- * across disarmed / armed_(home|away|night|vacation|custom_bypass) /
9852
- * arming / pending / triggered / disarming.
9853
- *
9854
- * Many panels require a PIN code on arm / disarm — the optional
9855
- * `code` field on the methods passes it through to the upstream
9856
- * service; it's NEVER persisted in the runtime slice or any event
9857
- * payload. The presence of a required code is signalled by
9858
- * `DeviceFeature.AlarmPinRequired` so the UI gates a code-entry
9859
- * field without a slice fetch.
9860
- *
9861
- * `availableModes` mirrors HA's `supported_features`-derived arm
9862
- * mode list — the UI renders only the buttons the panel accepts.
9863
- */
9864
- var AlarmStateSchema = _enum([
9865
- "disarmed",
9866
- "armed_home",
9867
- "armed_away",
9868
- "armed_night",
9869
- "armed_vacation",
9870
- "armed_custom_bypass",
9871
- "arming",
9872
- "disarming",
9873
- "pending",
9874
- "triggered"
9875
- ]);
9876
- var AlarmArmModeSchema = _enum([
9877
- "home",
9878
- "away",
9879
- "night",
9880
- "vacation",
9881
- "custom_bypass"
9882
- ]);
9883
- object({
9884
- /** Current lifecycle state. */
9885
- state: AlarmStateSchema,
9886
- /** Subset of arm modes the panel accepts. UI renders one button per
9887
- * mode in this list. */
9888
- availableModes: array(AlarmArmModeSchema),
9889
- /** Whether the panel requires a PIN on arm / disarm. Mirrors
9890
- * `DeviceFeature.AlarmPinRequired` for slice consumers. */
9891
- requiresCode: boolean(),
9892
- /** Ms epoch when the slice was last updated. */
9893
- lastChangedAt: number()
9894
- });
9895
- DeviceType.AlarmPanel, method(object({
9896
- deviceId: number().int().nonnegative(),
9897
- mode: AlarmArmModeSchema,
9898
- /** Optional PIN code. Required when `requiresCode === true`.
9899
- * Passed through to the upstream service; never persisted. */
9900
- code: string().min(1).optional()
9901
- }), _void(), {
9902
- kind: "mutation",
9903
- auth: "admin"
9904
- }), method(object({
9905
- deviceId: number().int().nonnegative(),
9906
- code: string().min(1).optional()
9907
- }), _void(), {
9908
- kind: "mutation",
9909
- auth: "admin"
9910
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
9911
- kind: "mutation",
9912
- auth: "admin"
9913
- });
9914
10496
  object({
9915
10497
  /** Current illuminance in lux (lx). */
9916
10498
  lux: number().min(0),
@@ -10385,7 +10967,15 @@ method(object({
10385
10967
  format: _enum(["gif", "mp4"]).default("gif"),
10386
10968
  maxWidth: number().int().min(120).max(1920).default(480),
10387
10969
  /** GIF only — MP4 keeps the source cadence. */
10388
- fps: number().int().min(1).max(15).default(5)
10970
+ fps: number().int().min(1).max(15).default(5),
10971
+ /**
10972
+ * Playback rate. A notification clip is GLANCED at on a lock screen,
10973
+ * not watched, so 2x is the default: the recipient sees the whole
10974
+ * passage in half the time and the GIF is half the bytes. `1` is real
10975
+ * time. Applies to MP4 as well — the operator set a speed, not a GIF
10976
+ * speed.
10977
+ */
10978
+ speed: number().min(1).max(8).default(2)
10389
10979
  }), object({
10390
10980
  base64: string(),
10391
10981
  mime: string(),
@@ -14967,6 +15557,20 @@ var GetInputSchema = object({ id: string() });
14967
15557
  var AddInputSchema = object({
14968
15558
  kind: string().min(1),
14969
15559
  name: string().min(1),
15560
+ /**
15561
+ * ADOPT an existing id instead of minting a new one.
15562
+ *
15563
+ * Written the day this cost an outage. A broker's id is not a detail: HA
15564
+ * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
15565
+ * lost from config and re-added as `ha_001` leaves every one of its devices
15566
+ * bound to a broker that no longer exists. Re-entering the password under the
15567
+ * ORIGINAL id turns a multi-step device migration back into re-entering a
15568
+ * password.
15569
+ *
15570
+ * A provider MUST refuse an id that is already in use — adopting a live
15571
+ * broker's id would silently take it over.
15572
+ */
15573
+ id: string().min(1).optional(),
14970
15574
  /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
14971
15575
  * `{baseUrl,accessToken}`). Validated by the kind-specific provider
14972
15576
  * branch on receipt — invalid shape rejects the add. */
@@ -15130,6 +15734,210 @@ method(object({ codec: string() }), boolean()), method(_void(), object({
15130
15734
  kind: "mutation",
15131
15735
  auth: "admin"
15132
15736
  });
15737
+ /**
15738
+ * Query filter for settings-store collections.
15739
+ */
15740
+ var QueryFilterSchema = object({
15741
+ where: record(string(), unknown()).optional(),
15742
+ whereIn: record(string(), array(unknown())).optional(),
15743
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
15744
+ orderBy: object({
15745
+ field: string(),
15746
+ direction: _enum(["asc", "desc"])
15747
+ }).optional(),
15748
+ limit: number().optional(),
15749
+ offset: number().optional()
15750
+ });
15751
+ /**
15752
+ * The predicate half of a filter, for BULK MUTATIONS.
15753
+ *
15754
+ * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
15755
+ * meaning for a statement that rewrites a set, and accepting them would invite
15756
+ * a caller to believe `limit` bounds the damage. Every field is optional here
15757
+ * only so the shape stays composable — the implementation REJECTS a filter
15758
+ * that compiles to no predicate, because that is the whole collection.
15759
+ */
15760
+ var MutationFilterSchema = object({
15761
+ where: record(string(), unknown()).optional(),
15762
+ whereIn: record(string(), array(unknown())).optional(),
15763
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional()
15764
+ });
15765
+ /** A single stored record: `{ id, data }`. */
15766
+ var SettingsRecordSchema = object({
15767
+ id: string(),
15768
+ data: record(string(), unknown())
15769
+ });
15770
+ /**
15771
+ * Column declaration for a structured (SQL-backed) collection.
15772
+ *
15773
+ * Logical types — the backend translates each to the matching SQLite
15774
+ * storage class and handles per-type marshaling:
15775
+ * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
15776
+ * - `JSON` — TEXT under the hood; serialised on write, parsed on read
15777
+ * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
15778
+ */
15779
+ var CollectionColumnSchema = object({
15780
+ name: string(),
15781
+ type: _enum([
15782
+ "TEXT",
15783
+ "INTEGER",
15784
+ "REAL",
15785
+ "JSON",
15786
+ "BOOLEAN"
15787
+ ]),
15788
+ primaryKey: boolean().optional(),
15789
+ notNull: boolean().optional(),
15790
+ unique: boolean().optional(),
15791
+ /**
15792
+ * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
15793
+ * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
15794
+ * collection surface simply cannot express their existing tables — which is
15795
+ * why they were still on the uncapped `table*` API.
15796
+ */
15797
+ defaultValue: union([
15798
+ string(),
15799
+ number(),
15800
+ boolean()
15801
+ ]).optional()
15802
+ });
15803
+ var CollectionIndexSchema = object({
15804
+ name: string(),
15805
+ columns: array(string()).readonly(),
15806
+ unique: boolean().optional()
15807
+ });
15808
+ method(object({
15809
+ namespace: string().optional(),
15810
+ collection: string(),
15811
+ key: string()
15812
+ }), unknown()), method(object({
15813
+ namespace: string().optional(),
15814
+ collection: string(),
15815
+ key: string(),
15816
+ value: unknown()
15817
+ }), _void(), { kind: "mutation" }), method(object({
15818
+ namespace: string().optional(),
15819
+ collection: string(),
15820
+ filter: QueryFilterSchema.optional()
15821
+ }), array(SettingsRecordSchema).readonly()), method(object({
15822
+ namespace: string().optional(),
15823
+ collection: string(),
15824
+ record: SettingsRecordSchema
15825
+ }), _void(), { kind: "mutation" }), method(object({
15826
+ namespace: string().optional(),
15827
+ collection: string(),
15828
+ id: string(),
15829
+ data: record(string(), unknown())
15830
+ }), _void(), { kind: "mutation" }), method(object({
15831
+ namespace: string().optional(),
15832
+ collection: string(),
15833
+ key: string()
15834
+ }), _void(), { kind: "mutation" }), method(object({
15835
+ namespace: string().optional(),
15836
+ collection: string(),
15837
+ filter: MutationFilterSchema
15838
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
15839
+ namespace: string().optional(),
15840
+ collection: string(),
15841
+ filter: MutationFilterSchema,
15842
+ data: record(string(), unknown())
15843
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
15844
+ namespace: string().optional(),
15845
+ collection: string(),
15846
+ filter: QueryFilterSchema.optional()
15847
+ }), number()), method(object({
15848
+ namespace: string().optional(),
15849
+ collection: string(),
15850
+ field: string(),
15851
+ bucketSize: number().int().positive(),
15852
+ origin: number().int(),
15853
+ filter: QueryFilterSchema.optional()
15854
+ }), array(object({
15855
+ bucket: number().int(),
15856
+ count: number().int()
15857
+ })).readonly()), method(object({
15858
+ namespace: string().optional(),
15859
+ collection: string()
15860
+ }), boolean()), method(object({
15861
+ namespace: string().optional(),
15862
+ collection: string(),
15863
+ columns: array(CollectionColumnSchema).readonly(),
15864
+ indexes: array(CollectionIndexSchema).readonly().optional()
15865
+ }), _void(), { kind: "mutation" });
15866
+ /**
15867
+ * What one engine says about itself. The orchestrator uses `kind` to pick
15868
+ * a registrant for a collection; `engineId` is what a log line names when
15869
+ * a call is routed or refused.
15870
+ */
15871
+ var EngineInfoSchema = object({
15872
+ engineId: string(),
15873
+ /**
15874
+ * `relational` — rows, columns, indexes, the surface `settings-store`
15875
+ * has always described. `vector` — an embedding store answering
15876
+ * similarity queries. A registrant declares exactly one; an engine that
15877
+ * does both registers twice, because "both" would make the routing
15878
+ * decision ambiguous at exactly the point it must not be.
15879
+ */
15880
+ kind: _enum(["relational", "vector"]),
15881
+ displayName: string()
15882
+ });
15883
+ method(_void(), EngineInfoSchema), method(object({
15884
+ namespace: string().optional(),
15885
+ collection: string(),
15886
+ key: string()
15887
+ }), unknown()), method(object({
15888
+ namespace: string().optional(),
15889
+ collection: string(),
15890
+ key: string(),
15891
+ value: unknown()
15892
+ }), _void(), { kind: "mutation" }), method(object({
15893
+ namespace: string().optional(),
15894
+ collection: string(),
15895
+ filter: QueryFilterSchema.optional()
15896
+ }), array(SettingsRecordSchema).readonly()), method(object({
15897
+ namespace: string().optional(),
15898
+ collection: string(),
15899
+ record: SettingsRecordSchema
15900
+ }), _void(), { kind: "mutation" }), method(object({
15901
+ namespace: string().optional(),
15902
+ collection: string(),
15903
+ id: string(),
15904
+ data: record(string(), unknown())
15905
+ }), _void(), { kind: "mutation" }), method(object({
15906
+ namespace: string().optional(),
15907
+ collection: string(),
15908
+ key: string()
15909
+ }), _void(), { kind: "mutation" }), method(object({
15910
+ namespace: string().optional(),
15911
+ collection: string(),
15912
+ filter: MutationFilterSchema
15913
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
15914
+ namespace: string().optional(),
15915
+ collection: string(),
15916
+ filter: MutationFilterSchema,
15917
+ data: record(string(), unknown())
15918
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
15919
+ namespace: string().optional(),
15920
+ collection: string(),
15921
+ filter: QueryFilterSchema.optional()
15922
+ }), number()), method(object({
15923
+ namespace: string().optional(),
15924
+ collection: string(),
15925
+ field: string(),
15926
+ bucketSize: number().int().positive(),
15927
+ origin: number().int(),
15928
+ filter: QueryFilterSchema.optional()
15929
+ }), array(object({
15930
+ bucket: number().int(),
15931
+ count: number().int()
15932
+ })).readonly()), method(object({
15933
+ namespace: string().optional(),
15934
+ collection: string()
15935
+ }), boolean()), method(object({
15936
+ namespace: string().optional(),
15937
+ collection: string(),
15938
+ columns: array(CollectionColumnSchema).readonly(),
15939
+ indexes: array(CollectionIndexSchema).readonly().optional()
15940
+ }), _void(), { kind: "mutation" });
15133
15941
  DeviceType.Camera;
15134
15942
  /**
15135
15943
  * `device-adoption` — generic discovery + adoption surface,
@@ -15833,7 +16641,20 @@ method(object({
15833
16641
  }), _void(), {
15834
16642
  kind: "mutation",
15835
16643
  auth: "admin"
15836
- }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({ addonId: string().optional() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
16644
+ }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({
16645
+ addonId: string().optional(),
16646
+ /**
16647
+ * `slim` omits `config` (returned `{}`), `metadata` (null) and the
16648
+ * `sourceInfo` derived from config — and skips the per-device settings
16649
+ * read that produces them. Everything identifying a device (id, name,
16650
+ * type, online, features, isCamera, parent/link ids) is unchanged.
16651
+ * Do not use it for dispatch routing, which needs `sourceInfo`.
16652
+ */
16653
+ projection: _enum(["full", "slim"]).optional(),
16654
+ /** Return only camera devices. Filtering server-side instead of
16655
+ * shipping 293 rows to find 12. */
16656
+ isCamera: boolean().optional()
16657
+ }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
15837
16658
  mode: DeviceLinkModeSchema,
15838
16659
  devices: array(LinkedDeviceSchema)
15839
16660
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
@@ -16272,14 +17093,14 @@ var LlmProfileSchema = object({
16272
17093
  /** ConfigUISchema tree passed through untyped on the wire (the
16273
17094
  * notification-output `ConfigSchemaPassthrough` precedent at
16274
17095
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
16275
- var ConfigSchemaPassthrough$1 = unknown();
17096
+ var ConfigSchemaPassthrough = unknown();
16276
17097
  var LlmProfileKindDescriptorSchema = object({
16277
17098
  kind: LlmProfileKindSchema,
16278
17099
  label: string(),
16279
17100
  icon: string(),
16280
17101
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
16281
17102
  addonId: string(),
16282
- configSchema: ConfigSchemaPassthrough$1
17103
+ configSchema: ConfigSchemaPassthrough
16283
17104
  });
16284
17105
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
16285
17106
  var LlmDefaultSchema = object({
@@ -16810,227 +17631,102 @@ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
16810
17631
  });
16811
17632
  method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
16812
17633
  /**
16813
- * notification-outputcanonical, capability-gated notification delivery.
16814
- *
16815
- * Apprise-derived model (see
16816
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
16817
- * callers emit ONE canonical `Notification`; each provider declares a
16818
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
16819
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
16820
- * message to what the kind supports callers never special-case a service.
16821
- *
16822
- * DESIGN DECISIONS (locked):
16823
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
16824
- * `setTargetEnabled`), each provider persisting via the `settings-store`
16825
- * cap. Rationale: the admin UI needs one uniform surface across the
16826
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
16827
- * alternative would fork the UI per addon and cannot host the
16828
- * discovery→adopt flow.
16829
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
16830
- * the generated cap-mount auto-`concatCollection`-fans them across every
16831
- * registered provider (notifiers addon + HA addon) so one catalog is
16832
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
16833
- * `addonId` the generated collection router extracts from the call input.
16834
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
16835
- * transformer) + UDS MsgPack both round-trip typed arrays already used by
16836
- * `storage` / `storage-provider` / `recording` caps over the same path. No
16837
- * base64 fallback needed.
16838
- *
16839
- * TODO (deferred, closed-set change — separate decision): add
16840
- * `providerKind: 'notify'` so notification providers surface on the unified
16841
- * admin "Integrations" page.
16842
- */
16843
- /**
16844
- * Zentik-derived typed-media enum — the superset across every kind. Each
16845
- * adapter picks what it supports and the degrade engine filters the rest.
16846
- */
16847
- var AttachmentMediaTypeSchema = _enum([
16848
- "image",
16849
- "video",
16850
- "gif",
16851
- "audio",
16852
- "icon"
16853
- ]);
16854
- /**
16855
- * A single attachment. Exactly one of `url` (remote source, most adapters
16856
- * prefer this) or `bytes` (inline source; required for Pushover-style
16857
- * bytes-only kinds) MUST be present — the degrade engine expresses a
16858
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
16859
- */
16860
- var AttachmentSchema = object({
16861
- mediaType: AttachmentMediaTypeSchema,
16862
- url: string().optional(),
16863
- bytes: _instanceof(Uint8Array).optional(),
16864
- mime: string().optional(),
16865
- name: string().optional()
16866
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
16867
- var NotificationFormatSchema = _enum([
16868
- "text",
16869
- "markdown",
16870
- "html"
17634
+ * core-blocksuser-authored TypeScript, stored in the kernel and executed in
17635
+ * its own process.
17636
+ *
17637
+ * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
17638
+ *
17639
+ * The first use is **owning devices without being a device provider**: a block
17640
+ * declares devices under a system or custom integration and drives their state,
17641
+ * with the same `ctx` an addon gets. Automations come later; nothing here
17642
+ * models a trigger.
17643
+ *
17644
+ * **Stated plainly, because it does not change by being true:** a block has an
17645
+ * addon's powers devices, storage, the event bus, `ctx.api`. It is a plugin
17646
+ * with no review step. What makes that survivable is not a sandbox, it is
17647
+ * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
17648
+ * so a block that throws or never returns is marked `failed` and visible
17649
+ * instead of taking the hub with it (D6). Every method here is admin-only, and
17650
+ * must stay so.
17651
+ */
17652
+ /** Where a block runs. The operator chooses a block driving a device on an
17653
+ * agent is the reason placement is not fixed to the hub. */
17654
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
17655
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
17656
+ * a failing block reads the same way a failing addon does. */
17657
+ var CoreBlockStatusSchema = _enum([
17658
+ "stopped",
17659
+ "starting",
17660
+ "running",
17661
+ "failed"
16871
17662
  ]);
16872
- /** A single tap-through action button. */
16873
- var NotificationActionSchema = object({
16874
- id: string(),
16875
- label: string(),
16876
- url: string().optional()
16877
- });
16878
- /**
16879
- * The canonical notification. `body` is the only hard field (Apprise model).
16880
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
16881
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
16882
- * the adapter maps this ordinal onto its native level. `level?` is an
16883
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
16884
- * `priority` for that one target.
16885
- */
16886
- var NotificationSchema = object({
16887
- body: string(),
16888
- title: string().optional(),
16889
- format: NotificationFormatSchema.default("text"),
16890
- priority: number().int().min(1).max(5).default(3),
16891
- level: string().optional(),
16892
- attachments: array(AttachmentSchema).optional(),
16893
- clickUrl: string().optional(),
16894
- actions: array(NotificationActionSchema).optional(),
16895
- sound: string().optional(),
16896
- ttl: number().optional(),
16897
- tag: string().optional(),
16898
- deviceId: number().optional(),
16899
- eventId: string().optional(),
16900
- metadata: record(string(), unknown()).optional()
16901
- });
16902
- /** One declared native severity/priority level for a kind. */
16903
- var TargetKindLevelSchema = object({
16904
- id: string(),
16905
- label: string(),
16906
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
16907
- ordinal: number().int().min(1).max(5).nullable(),
16908
- flags: object({
16909
- critical: boolean().optional(),
16910
- silent: boolean().optional(),
16911
- noPush: boolean().optional()
16912
- }).optional(),
16913
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
16914
- requires: array(string()).optional(),
16915
- description: string().optional()
16916
- });
16917
- /** The full capability block consulted before dispatch. */
16918
- var TargetKindCapsSchema = object({
16919
- attachments: object({
16920
- mediaTypes: array(AttachmentMediaTypeSchema),
16921
- mode: _enum([
16922
- "url",
16923
- "bytes",
16924
- "both"
16925
- ]),
16926
- max: number().int().nonnegative(),
16927
- maxBytes: number().int().positive().optional()
16928
- }),
16929
- /** Max action buttons (0 = none). */
16930
- actions: number().int().nonnegative(),
16931
- levels: array(TargetKindLevelSchema),
16932
- format: array(NotificationFormatSchema),
16933
- clickUrl: boolean(),
16934
- sound: boolean(),
16935
- ttl: boolean(),
16936
- bodyMaxLen: number().int().positive()
16937
- });
16938
- /**
16939
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
16940
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
16941
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
16942
- * the union is large and not meant for runtime validation here; the exported
16943
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
16944
- */
16945
- var ConfigSchemaPassthrough = unknown();
16946
- var TargetKindSchema = object({
16947
- kind: string(),
16948
- label: string(),
16949
- icon: string(),
16950
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
16951
- addonId: string(),
16952
- /**
16953
- * URL of the kind's bundled BRAND icon, served by the providing addon over
16954
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
16955
- * when the addon bundles no icon for that kind — the client then falls back
16956
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
16957
- *
16958
- * Root-relative on purpose: it resolves against whatever origin serves a web
16959
- * client, and a native client joins it onto its own hub base.
16960
- *
16961
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
16962
- * field that survived only because the runtime cap-router forwards provider
16963
- * output verbatim — so every consumer had to re-declare it by hand to stop
16964
- * its own Zod parse from stripping it, and the whole arrangement would have
16965
- * broken silently the moment output validation was tightened anywhere.
16966
- */
16967
- iconUrl: string().optional(),
17663
+ /** Client-authored fields. */
17664
+ var CoreBlockInputSchema = object({
17665
+ name: string().min(1).max(120),
17666
+ /** TypeScript source. Compiled server-side before it is ever stored — a
17667
+ * block that does not compile is a fork failure the operator would meet
17668
+ * minutes later, in a log, instead of in the editor. */
17669
+ code: string().max(2e5),
17670
+ enabled: boolean(),
17671
+ placement: CoreBlockPlacementSchema,
16968
17672
  /**
16969
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
16970
- *
16971
- * The server knows this and therefore says it, because the client cannot
16972
- * safely guess: a React-Native client renders SVG and raster through two
16973
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
16974
- * not decode SVG on iOS/Android), so without this it silently fell back to a
16975
- * placeholder glyph for every vector icon while the web build looked fine.
16976
- *
16977
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
16978
- * not been updated — a client that cannot determine the type should prefer
16979
- * its raster path, which is the safe default for an unknown image.
17673
+ * Integration the block's devices hang from. Absent = the system integration
17674
+ * blocks share. A block may declare its own instead.
16980
17675
  */
16981
- iconMediaType: string().optional(),
16982
- configSchema: ConfigSchemaPassthrough,
16983
- supportsDiscovery: boolean(),
16984
- caps: TargetKindCapsSchema
17676
+ integrationId: string().optional()
16985
17677
  });
16986
- /**
16987
- * A persisted target. `config` holds secrets; providers REDACT secret fields
16988
- * (return a presence marker only) when serving `listTargets` — never
16989
- * round-trip a stored secret to the UI.
16990
- */
16991
- var TargetSchema = object({
17678
+ /** A stored block. */
17679
+ var CoreBlockSchema = CoreBlockInputSchema.extend({
16992
17680
  id: string(),
16993
- name: string(),
16994
- kind: string(),
16995
- addonId: string(),
16996
- enabled: boolean(),
16997
- config: record(string(), unknown())
16998
- });
16999
- /** A discovery-surfaced candidate (config is partial + non-secret). */
17000
- var DiscoveredTargetSchema = object({
17001
- kind: string(),
17002
- suggestedName: string(),
17003
- config: record(string(), unknown())
17004
- });
17005
- /** The degrade engine's report — what was resolved / dropped / degraded. */
17006
- var RenderedAsSchema = object({
17007
- level: string(),
17008
- format: NotificationFormatSchema,
17009
- attachmentsSent: number().int().nonnegative(),
17010
- actionsSent: number().int().nonnegative(),
17011
- truncated: boolean(),
17012
- dropped: array(string())
17681
+ createdAt: number(),
17682
+ updatedAt: number(),
17683
+ /** Server-stamped author. */
17684
+ createdBy: string(),
17685
+ status: CoreBlockStatusSchema,
17686
+ /**
17687
+ * Why the block is not running, when it is not. The operator's ONLY window
17688
+ * into a block that failed at load — a block that is silently absent is the
17689
+ * failure mode this whole feature has to avoid.
17690
+ */
17691
+ lastError: string().optional(),
17692
+ /** Ms epoch of the last state change. */
17693
+ lastChangedAt: number()
17013
17694
  });
17014
- var SendResultSchema = object({
17015
- success: boolean(),
17695
+ /** What a compile attempt produced. */
17696
+ var CoreBlockCompileResultSchema = object({
17697
+ ok: boolean(),
17698
+ /** Present when `ok` is false — the first error, in the author's words. */
17016
17699
  error: string().optional(),
17017
- renderedAs: RenderedAsSchema.optional()
17700
+ line: number().optional(),
17701
+ column: number().optional()
17018
17702
  });
17019
- /** Same shape as SendResult kept as a distinct name for the test panel. */
17020
- var TestResultSchema = SendResultSchema;
17021
- method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
17022
- kind: string(),
17023
- config: record(string(), unknown()).optional()
17024
- }), array(DiscoveredTargetSchema)), method(object({
17025
- targetId: string(),
17026
- notification: NotificationSchema
17027
- }), SendResultSchema, { kind: "mutation" }), method(object({
17028
- targetId: string(),
17029
- sample: NotificationSchema.optional()
17030
- }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
17031
- targetId: string(),
17703
+ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }), method(object({ blockId: string() }), object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }), method(object({ block: CoreBlockInputSchema }), object({ block: CoreBlockSchema }), {
17704
+ kind: "mutation",
17705
+ auth: "admin",
17706
+ caller: "required"
17707
+ }), method(object({
17708
+ blockId: string(),
17709
+ block: CoreBlockInputSchema.partial()
17710
+ }), object({ block: CoreBlockSchema }), {
17711
+ kind: "mutation",
17712
+ auth: "admin",
17713
+ caller: "required"
17714
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
17715
+ kind: "mutation",
17716
+ auth: "admin"
17717
+ }), method(object({
17718
+ blockId: string(),
17032
17719
  enabled: boolean()
17033
- }), _void(), { kind: "mutation" });
17720
+ }), object({ block: CoreBlockSchema }), {
17721
+ kind: "mutation",
17722
+ auth: "admin"
17723
+ }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
17724
+ kind: "mutation",
17725
+ auth: "admin"
17726
+ }), method(object({}), object({ libs: array(object({
17727
+ filePath: string(),
17728
+ content: string()
17729
+ })) }), { auth: "admin" });
17034
17730
  /**
17035
17731
  * Zod schemas for persisted record types.
17036
17732
  *
@@ -17278,6 +17974,11 @@ var EventKindDescriptorSchema = object({
17278
17974
  deviceId: number()
17279
17975
  })
17280
17976
  });
17977
+ /** One camera's event vocabulary, as returned by `listEventKindsBatch`. */
17978
+ var EventKindsForDeviceSchema = object({
17979
+ deviceId: number(),
17980
+ kinds: array(EventKindDescriptorSchema).readonly()
17981
+ });
17281
17982
  var SensorEventSchema = object({
17282
17983
  id: string(),
17283
17984
  /** The CAMERA the event is attributed to (a sensor linked to N cameras
@@ -17534,6 +18235,19 @@ var MediaFileSchema = object({
17534
18235
  sizeBytes: number(),
17535
18236
  timestamp: number()
17536
18237
  });
18238
+ /**
18239
+ * One media row WITHOUT its bytes.
18240
+ *
18241
+ * A track's media is 5-8 MB of base64 (measured: 7.67 MB across 23 files for a
18242
+ * 140 s track), and a client that renders tiles from the media data plane needs
18243
+ * to know only WHAT EXISTS — the bytes then arrive per tile, lazily, over HTTP
18244
+ * with an immutable cache, instead of all at once inside a tRPC response that
18245
+ * blocks the whole view.
18246
+ *
18247
+ * `sizeBytes` is carried because it is what lets a client decide between the
18248
+ * stored blob and a `?variant=thumb` rendering without fetching either.
18249
+ */
18250
+ var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
17537
18251
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
17538
18252
  var MAX_EVENT_QUERY_LIMIT = 5e3;
17539
18253
  var DeviceEventQueryInput = object({
@@ -17683,7 +18397,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17683
18397
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
17684
18398
  kind: "mutation",
17685
18399
  auth: "admin"
17686
- }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({
18400
+ }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({ deviceIds: array(number()).min(1).max(200) }), array(EventKindsForDeviceSchema).readonly()), method(object({
17687
18401
  deviceId: number(),
17688
18402
  since: number().optional(),
17689
18403
  until: number().optional(),
@@ -17757,7 +18471,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17757
18471
  }), array(MediaFileSchema).readonly()), method(object({
17758
18472
  trackId: string(),
17759
18473
  kinds: array(MediaFileKindEnum).optional()
17760
- }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
18474
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
17761
18475
  deviceId: number(),
17762
18476
  timestamp: number(),
17763
18477
  frameWidth: number(),
@@ -18387,6 +19101,28 @@ var ServerUpdateStateSchema = _enum([
18387
19101
  "pending-restart",
18388
19102
  "awaiting-confirmation"
18389
19103
  ]);
19104
+ var ImageContractSchema = object({
19105
+ state: _enum([
19106
+ "in-sync",
19107
+ "behind-patch",
19108
+ "behind-series",
19109
+ "ahead",
19110
+ "unknown"
19111
+ ]),
19112
+ /** The baked seed closure version — the image/app-bundle fingerprint. */
19113
+ seedVersion: string().nullable(),
19114
+ /** Best-known version the deployment contract delivers today. */
19115
+ contractVersion: string().nullable(),
19116
+ /**
19117
+ * Where `contractVersion` came from: a real registry check (`registry`), or
19118
+ * the node's own running version (`running` — a node can never run code
19119
+ * newer than the newest release, so `seed < running` proves image staleness
19120
+ * even before any registry check has run).
19121
+ */
19122
+ contractSource: _enum(["registry", "running"]).nullable(),
19123
+ /** One operator-grade sentence: this node runs image X; the contract says Y. */
19124
+ message: string()
19125
+ });
18390
19126
  var ServerRollbackInfoSchema = object({
18391
19127
  /** The version that failed (or was manually rolled back). */
18392
19128
  fromVersion: string(),
@@ -18423,7 +19159,12 @@ var ServerPackageStatusSchema = object({
18423
19159
  * versions are being IGNORED. Surfaced as a warning in the UI.
18424
19160
  */
18425
19161
  stateFileCorrupt: boolean(),
18426
- lastCheckedAtMs: number().nullable()
19162
+ lastCheckedAtMs: number().nullable(),
19163
+ /**
19164
+ * Seed-vs-contract verdict (see {@link ImageContractSchema}). Optional for
19165
+ * version skew: an older provider's payload simply omits it.
19166
+ */
19167
+ imageContract: ImageContractSchema.optional()
18427
19168
  });
18428
19169
  var ServerUpdateCheckResultSchema = object({
18429
19170
  packageName: string(),
@@ -18458,101 +19199,6 @@ version: string().optional() }), ServerUpdateActionResultSchema, {
18458
19199
  auth: "admin"
18459
19200
  });
18460
19201
  /**
18461
- * Query filter for settings-store collections.
18462
- */
18463
- var QueryFilterSchema = object({
18464
- where: record(string(), unknown()).optional(),
18465
- whereIn: record(string(), array(unknown())).optional(),
18466
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
18467
- orderBy: object({
18468
- field: string(),
18469
- direction: _enum(["asc", "desc"])
18470
- }).optional(),
18471
- limit: number().optional(),
18472
- offset: number().optional()
18473
- });
18474
- /** A single stored record: `{ id, data }`. */
18475
- var SettingsRecordSchema = object({
18476
- id: string(),
18477
- data: record(string(), unknown())
18478
- });
18479
- /**
18480
- * Column declaration for a structured (SQL-backed) collection.
18481
- *
18482
- * Logical types — the backend translates each to the matching SQLite
18483
- * storage class and handles per-type marshaling:
18484
- * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
18485
- * - `JSON` — TEXT under the hood; serialised on write, parsed on read
18486
- * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
18487
- */
18488
- var CollectionColumnSchema = object({
18489
- name: string(),
18490
- type: _enum([
18491
- "TEXT",
18492
- "INTEGER",
18493
- "REAL",
18494
- "JSON",
18495
- "BOOLEAN"
18496
- ]),
18497
- primaryKey: boolean().optional(),
18498
- notNull: boolean().optional(),
18499
- unique: boolean().optional()
18500
- });
18501
- var CollectionIndexSchema = object({
18502
- name: string(),
18503
- columns: array(string()).readonly(),
18504
- unique: boolean().optional()
18505
- });
18506
- method(object({
18507
- namespace: string().optional(),
18508
- collection: string(),
18509
- key: string()
18510
- }), unknown()), method(object({
18511
- namespace: string().optional(),
18512
- collection: string(),
18513
- key: string(),
18514
- value: unknown()
18515
- }), _void(), { kind: "mutation" }), method(object({
18516
- namespace: string().optional(),
18517
- collection: string(),
18518
- filter: QueryFilterSchema.optional()
18519
- }), array(SettingsRecordSchema).readonly()), method(object({
18520
- namespace: string().optional(),
18521
- collection: string(),
18522
- record: SettingsRecordSchema
18523
- }), _void(), { kind: "mutation" }), method(object({
18524
- namespace: string().optional(),
18525
- collection: string(),
18526
- id: string(),
18527
- data: record(string(), unknown())
18528
- }), _void(), { kind: "mutation" }), method(object({
18529
- namespace: string().optional(),
18530
- collection: string(),
18531
- key: string()
18532
- }), _void(), { kind: "mutation" }), method(object({
18533
- namespace: string().optional(),
18534
- collection: string(),
18535
- filter: QueryFilterSchema.optional()
18536
- }), number()), method(object({
18537
- namespace: string().optional(),
18538
- collection: string(),
18539
- field: string(),
18540
- bucketSize: number().int().positive(),
18541
- origin: number().int(),
18542
- filter: QueryFilterSchema.optional()
18543
- }), array(object({
18544
- bucket: number().int(),
18545
- count: number().int()
18546
- })).readonly()), method(object({
18547
- namespace: string().optional(),
18548
- collection: string()
18549
- }), boolean()), method(object({
18550
- namespace: string().optional(),
18551
- collection: string(),
18552
- columns: array(CollectionColumnSchema).readonly(),
18553
- indexes: array(CollectionIndexSchema).readonly().optional()
18554
- }), _void(), { kind: "mutation" });
18555
- /**
18556
19202
  * `smtp-provider` — pluggable email delivery surface.
18557
19203
  *
18558
19204
  * Collection cap: a deployment may install multiple SMTP relays (e.g.
@@ -22415,6 +23061,54 @@ Object.freeze({
22415
23061
  addonId: null,
22416
23062
  access: "create"
22417
23063
  },
23064
+ "coreBlocks.compile": {
23065
+ capName: "core-blocks",
23066
+ capScope: "system",
23067
+ addonId: null,
23068
+ access: "create"
23069
+ },
23070
+ "coreBlocks.create": {
23071
+ capName: "core-blocks",
23072
+ capScope: "system",
23073
+ addonId: null,
23074
+ access: "create"
23075
+ },
23076
+ "coreBlocks.delete": {
23077
+ capName: "core-blocks",
23078
+ capScope: "system",
23079
+ addonId: null,
23080
+ access: "delete"
23081
+ },
23082
+ "coreBlocks.get": {
23083
+ capName: "core-blocks",
23084
+ capScope: "system",
23085
+ addonId: null,
23086
+ access: "view"
23087
+ },
23088
+ "coreBlocks.getTypeDefs": {
23089
+ capName: "core-blocks",
23090
+ capScope: "system",
23091
+ addonId: null,
23092
+ access: "view"
23093
+ },
23094
+ "coreBlocks.list": {
23095
+ capName: "core-blocks",
23096
+ capScope: "system",
23097
+ addonId: null,
23098
+ access: "view"
23099
+ },
23100
+ "coreBlocks.setEnabled": {
23101
+ capName: "core-blocks",
23102
+ capScope: "system",
23103
+ addonId: null,
23104
+ access: "create"
23105
+ },
23106
+ "coreBlocks.update": {
23107
+ capName: "core-blocks",
23108
+ capScope: "system",
23109
+ addonId: null,
23110
+ access: "create"
23111
+ },
22418
23112
  "cover.close": {
22419
23113
  capName: "cover",
22420
23114
  capScope: "device",
@@ -22451,6 +23145,84 @@ Object.freeze({
22451
23145
  addonId: null,
22452
23146
  access: "view"
22453
23147
  },
23148
+ "dataStoreProvider.count": {
23149
+ capName: "data-store-provider",
23150
+ capScope: "system",
23151
+ addonId: null,
23152
+ access: "view"
23153
+ },
23154
+ "dataStoreProvider.declareCollection": {
23155
+ capName: "data-store-provider",
23156
+ capScope: "system",
23157
+ addonId: null,
23158
+ access: "create"
23159
+ },
23160
+ "dataStoreProvider.delete": {
23161
+ capName: "data-store-provider",
23162
+ capScope: "system",
23163
+ addonId: null,
23164
+ access: "delete"
23165
+ },
23166
+ "dataStoreProvider.deleteWhere": {
23167
+ capName: "data-store-provider",
23168
+ capScope: "system",
23169
+ addonId: null,
23170
+ access: "delete"
23171
+ },
23172
+ "dataStoreProvider.get": {
23173
+ capName: "data-store-provider",
23174
+ capScope: "system",
23175
+ addonId: null,
23176
+ access: "view"
23177
+ },
23178
+ "dataStoreProvider.getEngineInfo": {
23179
+ capName: "data-store-provider",
23180
+ capScope: "system",
23181
+ addonId: null,
23182
+ access: "view"
23183
+ },
23184
+ "dataStoreProvider.histogram": {
23185
+ capName: "data-store-provider",
23186
+ capScope: "system",
23187
+ addonId: null,
23188
+ access: "view"
23189
+ },
23190
+ "dataStoreProvider.insert": {
23191
+ capName: "data-store-provider",
23192
+ capScope: "system",
23193
+ addonId: null,
23194
+ access: "create"
23195
+ },
23196
+ "dataStoreProvider.isEmpty": {
23197
+ capName: "data-store-provider",
23198
+ capScope: "system",
23199
+ addonId: null,
23200
+ access: "view"
23201
+ },
23202
+ "dataStoreProvider.query": {
23203
+ capName: "data-store-provider",
23204
+ capScope: "system",
23205
+ addonId: null,
23206
+ access: "view"
23207
+ },
23208
+ "dataStoreProvider.set": {
23209
+ capName: "data-store-provider",
23210
+ capScope: "system",
23211
+ addonId: null,
23212
+ access: "create"
23213
+ },
23214
+ "dataStoreProvider.update": {
23215
+ capName: "data-store-provider",
23216
+ capScope: "system",
23217
+ addonId: null,
23218
+ access: "create"
23219
+ },
23220
+ "dataStoreProvider.updateWhere": {
23221
+ capName: "data-store-provider",
23222
+ capScope: "system",
23223
+ addonId: null,
23224
+ access: "create"
23225
+ },
22454
23226
  "dayNight.getOptions": {
22455
23227
  capName: "day-night",
22456
23228
  capScope: "device",
@@ -24245,18 +25017,36 @@ Object.freeze({
24245
25017
  addonId: null,
24246
25018
  access: "create"
24247
25019
  },
25020
+ "notificationRules.cancelSnooze": {
25021
+ capName: "notification-rules",
25022
+ capScope: "system",
25023
+ addonId: null,
25024
+ access: "create"
25025
+ },
24248
25026
  "notificationRules.createRule": {
24249
25027
  capName: "notification-rules",
24250
25028
  capScope: "system",
24251
25029
  addonId: null,
24252
25030
  access: "create"
24253
25031
  },
25032
+ "notificationRules.createSnooze": {
25033
+ capName: "notification-rules",
25034
+ capScope: "system",
25035
+ addonId: null,
25036
+ access: "create"
25037
+ },
24254
25038
  "notificationRules.deleteRule": {
24255
25039
  capName: "notification-rules",
24256
25040
  capScope: "system",
24257
25041
  addonId: null,
24258
25042
  access: "delete"
24259
25043
  },
25044
+ "notificationRules.getAlarmConfig": {
25045
+ capName: "notification-rules",
25046
+ capScope: "system",
25047
+ addonId: null,
25048
+ access: "view"
25049
+ },
24260
25050
  "notificationRules.getConditionCatalog": {
24261
25051
  capName: "notification-rules",
24262
25052
  capScope: "system",
@@ -24281,6 +25071,18 @@ Object.freeze({
24281
25071
  addonId: null,
24282
25072
  access: "view"
24283
25073
  },
25074
+ "notificationRules.listSnoozes": {
25075
+ capName: "notification-rules",
25076
+ capScope: "system",
25077
+ addonId: null,
25078
+ access: "view"
25079
+ },
25080
+ "notificationRules.setAlarmConfig": {
25081
+ capName: "notification-rules",
25082
+ capScope: "system",
25083
+ addonId: null,
25084
+ access: "create"
25085
+ },
24284
25086
  "notificationRules.setRuleEnabled": {
24285
25087
  capName: "notification-rules",
24286
25088
  capScope: "system",
@@ -24485,6 +25287,12 @@ Object.freeze({
24485
25287
  addonId: null,
24486
25288
  access: "view"
24487
25289
  },
25290
+ "pipelineAnalytics.listEventKindsBatch": {
25291
+ capName: "pipeline-analytics",
25292
+ capScope: "device",
25293
+ addonId: null,
25294
+ access: "view"
25295
+ },
24488
25296
  "pipelineAnalytics.listOpsLog": {
24489
25297
  capName: "pipeline-analytics",
24490
25298
  capScope: "device",
@@ -24497,6 +25305,12 @@ Object.freeze({
24497
25305
  addonId: null,
24498
25306
  access: "view"
24499
25307
  },
25308
+ "pipelineAnalytics.listTrackMedia": {
25309
+ capName: "pipeline-analytics",
25310
+ capScope: "device",
25311
+ addonId: null,
25312
+ access: "view"
25313
+ },
24500
25314
  "pipelineAnalytics.listTracks": {
24501
25315
  capName: "pipeline-analytics",
24502
25316
  capScope: "device",
@@ -25529,6 +26343,12 @@ Object.freeze({
25529
26343
  addonId: null,
25530
26344
  access: "delete"
25531
26345
  },
26346
+ "settingsStore.deleteWhere": {
26347
+ capName: "settings-store",
26348
+ capScope: "system",
26349
+ addonId: null,
26350
+ access: "delete"
26351
+ },
25532
26352
  "settingsStore.get": {
25533
26353
  capName: "settings-store",
25534
26354
  capScope: "system",
@@ -25571,6 +26391,12 @@ Object.freeze({
25571
26391
  addonId: null,
25572
26392
  access: "create"
25573
26393
  },
26394
+ "settingsStore.updateWhere": {
26395
+ capName: "settings-store",
26396
+ capScope: "system",
26397
+ addonId: null,
26398
+ access: "create"
26399
+ },
25574
26400
  "smtpProvider.getStatus": {
25575
26401
  capName: "smtp-provider",
25576
26402
  capScope: "system",
@@ -37338,10 +38164,12 @@ async function prepareStream(request, sessions, bctx) {
37338
38164
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
37339
38165
  });
37340
38166
  let videoOutSrtp;
38167
+ let videoOutSrtcp;
37341
38168
  let audioOutSrtp;
37342
38169
  let audioOutSrtcp;
37343
38170
  try {
37344
38171
  videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
38172
+ videoOutSrtcp = makeOutSrtcp(request.video.srtp_key, request.video.srtp_salt);
37345
38173
  audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
37346
38174
  audioOutSrtcp = makeOutSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
37347
38175
  } catch (err) {
@@ -37375,6 +38203,12 @@ async function prepareStream(request, sessions, bctx) {
37375
38203
  videoUdp,
37376
38204
  videoLoopUdp,
37377
38205
  videoOutSrtp,
38206
+ videoOutSrtcp,
38207
+ videoOutPacketCount: 0,
38208
+ videoOutOctetCount: 0,
38209
+ videoOutLastRtpTimestamp: 0,
38210
+ videoOutLastRtcpAt: 0,
38211
+ videoRtcpIntervalMs: 5e3,
37378
38212
  videoSendGate: null,
37379
38213
  videoSsrc,
37380
38214
  hapAudioPort: request.audio.port,
@@ -37419,9 +38253,7 @@ async function prepareStream(request, sessions, bctx) {
37419
38253
  audioLoopUdp.on("message", (rtpPacket) => {
37420
38254
  audioPacketsForwarded += 1;
37421
38255
  if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
37422
- if (audioPacketsForwarded === 1) process.stderr.write(`[AUDIO-LISTENER] first packet — about to call forwardEncryptedRtp len=${rtpPacket.length} typeofFn=${typeof forwardEncryptedRtp}\n`);
37423
38256
  forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
37424
- if (audioPacketsForwarded === 1) process.stderr.write("[AUDIO-LISTENER] forwardEncryptedRtp returned for first packet\n");
37425
38257
  });
37426
38258
  audioUdp.on("message", (packet, rinfo) => {
37427
38259
  handleIncomingAudioRtp(session, packet, rinfo.address, bctx).catch((err) => {
@@ -37543,32 +38375,48 @@ function ntpTime() {
37543
38375
  return secs << 32n | frac;
37544
38376
  }
37545
38377
  /**
37546
- * Emit one RTCP Sender Report for the downstream audio leg. Carries
37547
- * the cumulative packet + octet counts plus the (NTP, RTP) timestamp
37548
- * pair that lets iOS align speaker playback against wall-clock time.
37549
- * Encrypted via the per-session SRTCP context (same master key/salt
37550
- * pair as the SRTP audio session) and sent to the same `(hapAddress,
37551
- * hapAudioPort)` the SRTP packets go to.
38378
+ * Emit one RTCP Sender Report for a downstream leg. Carries the cumulative
38379
+ * packet + octet counts plus the (NTP, RTP) timestamp pair the controller
38380
+ * needs to anchor that leg against wall-clock time. Encrypted via the leg's
38381
+ * SRTCP context (same master key/salt pair as its SRTP session) and sent to
38382
+ * the same `(hapAddress, port)` the SRTP packets go to.
38383
+ *
38384
+ * BOTH legs need this, not just audio. HAP negotiates `rtcp_interval`
38385
+ * separately for video and audio, and a Sender Report is the only thing that
38386
+ * pairs an NTP instant with an RTP timestamp — without it the controller
38387
+ * cannot place the stream on a clock. The audio leg alone was covered for a
38388
+ * year (the speaker went mute without it, which made the omission visible);
38389
+ * the video leg has the same requirement and no equally loud symptom.
37552
38390
  */
37553
- function sendAudioRtcpSr(session, log) {
37554
- if (!session.audioOutSrtcp) return;
38391
+ function sendRtcpSr(session, kind, log) {
38392
+ const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
38393
+ if (!srtcp) return;
38394
+ const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38395
+ const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
37555
38396
  try {
37556
38397
  const sr = new import_src.RtcpSrPacket({
37557
- ssrc: session.audioOutSsrc,
38398
+ ssrc: kind === "video" ? session.videoSsrc : session.audioOutSsrc,
37558
38399
  senderInfo: new import_src.RtcpSenderInfo({
37559
38400
  ntpTimestamp: ntpTime(),
37560
- rtpTimestamp: session.audioOutLastRtpTimestamp,
37561
- packetCount: session.audioOutPacketCount,
37562
- octetCount: session.audioOutOctetCount
38401
+ rtpTimestamp: kind === "video" ? session.videoOutLastRtpTimestamp : session.audioOutLastRtpTimestamp,
38402
+ packetCount: kind === "video" ? session.videoOutPacketCount : session.audioOutPacketCount,
38403
+ octetCount: kind === "video" ? session.videoOutOctetCount : session.audioOutOctetCount
37563
38404
  })
37564
38405
  });
37565
- const encrypted = session.audioOutSrtcp.encrypt(sr.serialize());
37566
- session.audioUdp.send(encrypted, session.hapAudioPort, session.hapAddress, (err) => {
37567
- if (err) log.debug("export-hap: audio RTCP send error", { meta: { error: err.message } });
38406
+ const encrypted = srtcp.encrypt(sr.serialize());
38407
+ sink.send(encrypted, port, session.hapAddress, (err) => {
38408
+ if (err) log.debug("export-hap: RTCP send error", { meta: {
38409
+ kind,
38410
+ error: err.message
38411
+ } });
37568
38412
  });
37569
- session.audioOutLastRtcpAt = Date.now();
38413
+ if (kind === "video") session.videoOutLastRtcpAt = Date.now();
38414
+ else session.audioOutLastRtcpAt = Date.now();
37570
38415
  } catch (err) {
37571
- log.debug("export-hap: audio RTCP SR build/encrypt failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
38416
+ log.debug("export-hap: RTCP SR build/encrypt failed", { meta: {
38417
+ kind,
38418
+ error: err instanceof Error ? err.message : String(err)
38419
+ } });
37572
38420
  }
37573
38421
  }
37574
38422
  /**
@@ -37587,21 +38435,18 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
37587
38435
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
37588
38436
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
37589
38437
  const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
37590
- if (kind === "audio" && session.audioOutPacketCount === 0) {
37591
- process.stderr.write(`[FORWARD-AUDIO-DIAG] hasSrtp=${!!srtp} hasGate=${!!gate} audioOutSsrc=${session.audioOutSsrc} scale=${session.audioIntervalScale} hasStartParams=${!!session.lastStartParams}\n`);
37592
- log.info("export-hap: forwardEncryptedRtp audio entry diag", { meta: {
37593
- hasSrtp: !!srtp,
37594
- hasGate: !!gate,
37595
- audioOutSsrc: session.audioOutSsrc,
37596
- audioIntervalScale: session.audioIntervalScale,
37597
- hasLastStartParams: !!session.lastStartParams
37598
- } });
37599
- }
37600
38438
  if (!srtp || !gate) return;
37601
38439
  gate.then(() => {
37602
38440
  try {
37603
38441
  const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
37604
38442
  let firstAudio = false;
38443
+ let firstVideo = false;
38444
+ if (kind === "video") {
38445
+ if (session.videoOutPacketCount === 0) firstVideo = true;
38446
+ session.videoOutPacketCount += 1;
38447
+ session.videoOutOctetCount += parsed.payload.length;
38448
+ session.videoOutLastRtpTimestamp = parsed.header.timestamp;
38449
+ }
37605
38450
  if (kind === "audio") {
37606
38451
  const originalTimestamp = parsed.header.timestamp;
37607
38452
  if (session.firstAudioOutTimestamp === null) {
@@ -37631,9 +38476,10 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
37631
38476
  error: err.message
37632
38477
  } });
37633
38478
  });
37634
- if (kind === "audio") {
37635
- if (firstAudio || Date.now() > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendAudioRtcpSr(session, log);
37636
- }
38479
+ const now = Date.now();
38480
+ if (kind === "video") {
38481
+ if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
38482
+ } else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
37637
38483
  } catch (err) {
37638
38484
  log.debug("export-hap: SRTP encrypt failed", { meta: {
37639
38485
  kind,
@@ -37671,6 +38517,11 @@ async function handleStreamRequest(request, sessions, bctx) {
37671
38517
  session.audioOutLastRtpTimestamp = 0;
37672
38518
  session.audioOutLastRtcpAt = 0;
37673
38519
  session.audioRtcpIntervalMs = (request.audio.rtcp_interval > 0 ? request.audio.rtcp_interval : 5) * 1e3;
38520
+ session.videoRtcpIntervalMs = (request.video.rtcp_interval > 0 ? request.video.rtcp_interval : 5) * 1e3;
38521
+ session.videoOutPacketCount = 0;
38522
+ session.videoOutOctetCount = 0;
38523
+ session.videoOutLastRtpTimestamp = 0;
38524
+ session.videoOutLastRtcpAt = 0;
37674
38525
  session.lastStartParams = {
37675
38526
  pt: request.video.pt,
37676
38527
  mtu: request.video.mtu,
@@ -37702,6 +38553,10 @@ async function handleStreamRequest(request, sessions, bctx) {
37702
38553
  if (session.ffmpeg) killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
37703
38554
  session.firstAudioOutTimestamp = null;
37704
38555
  session.audioOutPacketCount = 0;
38556
+ session.videoOutPacketCount = 0;
38557
+ session.videoOutOctetCount = 0;
38558
+ session.videoOutLastRtpTimestamp = 0;
38559
+ session.videoOutLastRtcpAt = 0;
37705
38560
  const startParams = {
37706
38561
  width: request.video.width,
37707
38562
  height: request.video.height,