@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.
@@ -35,7 +35,7 @@ let node_fs_promises = require("node:fs/promises");
35
35
  node_fs_promises = __toESM(node_fs_promises);
36
36
  let node_dgram = require("node:dgram");
37
37
  let node_os = require("node:os");
38
- //#region ../types/dist/event-category-Bz24uP1U.mjs
38
+ //#region ../types/dist/event-category-41fKf-q9.mjs
39
39
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
40
40
  EventCategory["SystemBoot"] = "system.boot";
41
41
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -95,6 +95,26 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
95
95
  */
96
96
  EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
97
97
  /**
98
+ * A liveness invariant of this node is false — it has no devices, every
99
+ * camera is offline, or a camera that was recording has stopped producing
100
+ * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
101
+ * `findingId` is stable across ticks), only after its boot grace period.
102
+ * AlertCenter raises a persistent operator-visible alert.
103
+ *
104
+ * It exists because on 2026-08-03 the hub ran four hours with zero devices
105
+ * and zero recordings while every surface stayed quiet.
106
+ *
107
+ * Payload: `{ findingId, severity, title, message, deviceId? }`.
108
+ */
109
+ EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
110
+ /**
111
+ * A previously reported liveness fault is true again. AlertCenter dismisses
112
+ * the matching `SystemLivenessFailed` alert.
113
+ *
114
+ * Payload: `{ findingId }`.
115
+ */
116
+ EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
117
+ /**
98
118
  * Monitor is attempting to reload a failed addon NOW. UI uses this
99
119
  * to show a spinner during the retry attempt. Same transient nature
100
120
  * as AddonRetryScheduled.
@@ -7345,14 +7365,7 @@ var RecordingConfigSchema = object({
7345
7365
  * windows only — existing sheets are immutable, and each window's index
7346
7366
  * carries its own tile dims so mixed-preset history renders correctly.
7347
7367
  */
7348
- scrubThumbnails: ScrubThumbnailPresetSchema.optional(),
7349
- /**
7350
- * OPT-IN thumbnail-strip generation for this camera: every keyframe of the
7351
- * low recording saved as a JPEG (the fast-drag scrub depth), a derived
7352
- * cache that eviction reclaims with the footage. Absent/false = no strips
7353
- * are written and scrub reads exact keyframes at every velocity.
7354
- */
7355
- stripsEnabled: boolean().optional()
7368
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7356
7369
  }).strict();
7357
7370
  /**
7358
7371
  * Ops-log — the durable, append-only operations audit shared by the
@@ -7414,7 +7427,7 @@ var OpsLogQueryInputSchema = object({
7414
7427
  /**
7415
7428
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7416
7429
  *
7417
- * One shape shared by the recorder's `relocateFootage` (segments + strips) and
7430
+ * One shape shared by the recorder's `relocateFootage` (segments) and
7418
7431
  * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7419
7432
  * page renders both movers with one component. Jobs are in-RAM (a restart
7420
7433
  * forgets them — re-running is safe by construction: copy-if-absent, delete
@@ -7436,7 +7449,7 @@ var RelocateJobSchema = object({
7436
7449
  toLocationId: string(),
7437
7450
  /** Scoped device, or null = every device. */
7438
7451
  deviceId: number().nullable(),
7439
- /** What the job moves (owner-addon specific: segments/strips or media). */
7452
+ /** What the job moves (owner-addon specific: segments or media). */
7440
7453
  entities: array(string()),
7441
7454
  filesMoved: number().int(),
7442
7455
  bytesMoved: number().int(),
@@ -7450,7 +7463,7 @@ var RelocateFootageInputSchema = object({
7450
7463
  deviceId: number().optional(),
7451
7464
  fromLocationId: string(),
7452
7465
  toLocationId: string(),
7453
- entities: array(_enum(["segments", "strips"])).optional(),
7466
+ entities: array(_enum(["segments"])).optional(),
7454
7467
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7455
7468
  * never allowed to starve live writers. */
7456
7469
  throttleMbps: number().min(1).max(1e3).optional()
@@ -9001,6 +9014,72 @@ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, Accessor
9001
9014
  DeviceFeature.BatteryOperated;
9002
9015
  new Set(["devices", "classes"]);
9003
9016
  /**
9017
+ * Alarm-panel cap. Models HA `alarm_control_panel.*` on
9018
+ * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
9019
+ * across disarmed / armed_(home|away|night|vacation|custom_bypass) /
9020
+ * arming / pending / triggered / disarming.
9021
+ *
9022
+ * Many panels require a PIN code on arm / disarm — the optional
9023
+ * `code` field on the methods passes it through to the upstream
9024
+ * service; it's NEVER persisted in the runtime slice or any event
9025
+ * payload. The presence of a required code is signalled by
9026
+ * `DeviceFeature.AlarmPinRequired` so the UI gates a code-entry
9027
+ * field without a slice fetch.
9028
+ *
9029
+ * `availableModes` mirrors HA's `supported_features`-derived arm
9030
+ * mode list — the UI renders only the buttons the panel accepts.
9031
+ */
9032
+ var AlarmStateSchema = _enum([
9033
+ "disarmed",
9034
+ "armed_home",
9035
+ "armed_away",
9036
+ "armed_night",
9037
+ "armed_vacation",
9038
+ "armed_custom_bypass",
9039
+ "arming",
9040
+ "disarming",
9041
+ "pending",
9042
+ "triggered"
9043
+ ]);
9044
+ var AlarmArmModeSchema = _enum([
9045
+ "home",
9046
+ "away",
9047
+ "night",
9048
+ "vacation",
9049
+ "custom_bypass"
9050
+ ]);
9051
+ object({
9052
+ /** Current lifecycle state. */
9053
+ state: AlarmStateSchema,
9054
+ /** Subset of arm modes the panel accepts. UI renders one button per
9055
+ * mode in this list. */
9056
+ availableModes: array(AlarmArmModeSchema),
9057
+ /** Whether the panel requires a PIN on arm / disarm. Mirrors
9058
+ * `DeviceFeature.AlarmPinRequired` for slice consumers. */
9059
+ requiresCode: boolean(),
9060
+ /** Ms epoch when the slice was last updated. */
9061
+ lastChangedAt: number()
9062
+ });
9063
+ DeviceType.AlarmPanel, method(object({
9064
+ deviceId: number().int().nonnegative(),
9065
+ mode: AlarmArmModeSchema,
9066
+ /** Optional PIN code. Required when `requiresCode === true`.
9067
+ * Passed through to the upstream service; never persisted. */
9068
+ code: string().min(1).optional()
9069
+ }), _void(), {
9070
+ kind: "mutation",
9071
+ auth: "admin"
9072
+ }), method(object({
9073
+ deviceId: number().int().nonnegative(),
9074
+ code: string().min(1).optional()
9075
+ }), _void(), {
9076
+ kind: "mutation",
9077
+ auth: "admin"
9078
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
9079
+ kind: "mutation",
9080
+ auth: "admin"
9081
+ });
9082
+ /**
9004
9083
  * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
9005
9084
  * motion-zones, and the detection zones/lines editor all speak this one
9006
9085
  * language so a single drawing-plane editor and the providers stay
@@ -9062,6 +9141,276 @@ var MaskGridDimsSchema = object({
9062
9141
  height: number()
9063
9142
  });
9064
9143
  /**
9144
+ * notification-output — canonical, capability-gated notification delivery.
9145
+ *
9146
+ * Apprise-derived model (see
9147
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
9148
+ * callers emit ONE canonical `Notification`; each provider declares a
9149
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
9150
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
9151
+ * message to what the kind supports — callers never special-case a service.
9152
+ *
9153
+ * DESIGN DECISIONS (locked):
9154
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
9155
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
9156
+ * cap. Rationale: the admin UI needs one uniform surface across the
9157
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
9158
+ * alternative would fork the UI per addon and cannot host the
9159
+ * discovery→adopt flow.
9160
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
9161
+ * the generated cap-mount auto-`concatCollection`-fans them across every
9162
+ * registered provider (notifiers addon + HA addon) so one catalog is
9163
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
9164
+ * `addonId` the generated collection router extracts from the call input.
9165
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
9166
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
9167
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
9168
+ * base64 fallback needed.
9169
+ *
9170
+ * TODO (deferred, closed-set change — separate decision): add
9171
+ * `providerKind: 'notify'` so notification providers surface on the unified
9172
+ * admin "Integrations" page.
9173
+ */
9174
+ /**
9175
+ * Zentik-derived typed-media enum — the superset across every kind. Each
9176
+ * adapter picks what it supports and the degrade engine filters the rest.
9177
+ */
9178
+ var AttachmentMediaTypeSchema = _enum([
9179
+ "image",
9180
+ "video",
9181
+ "gif",
9182
+ "audio",
9183
+ "icon"
9184
+ ]);
9185
+ /**
9186
+ * A single attachment. Exactly one of `url` (remote source, most adapters
9187
+ * prefer this) or `bytes` (inline source; required for Pushover-style
9188
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
9189
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
9190
+ */
9191
+ var AttachmentSchema = object({
9192
+ mediaType: AttachmentMediaTypeSchema,
9193
+ url: string().optional(),
9194
+ bytes: _instanceof(Uint8Array).optional(),
9195
+ mime: string().optional(),
9196
+ name: string().optional()
9197
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
9198
+ var NotificationFormatSchema = _enum([
9199
+ "text",
9200
+ "markdown",
9201
+ "html"
9202
+ ]);
9203
+ /**
9204
+ * The CLOSED icon vocabulary an action button may use.
9205
+ *
9206
+ * A closed set, not a free string, and that is the whole point: an arbitrary
9207
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
9208
+ * notices — the same class of gap as a zone vocabulary nothing produced
9209
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
9210
+ * declares `actionIcons: false` and the degrade engine strips the field.
9211
+ *
9212
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
9213
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
9214
+ * differently.
9215
+ */
9216
+ var NotificationActionIconSchema = _enum([
9217
+ "acknowledge",
9218
+ "dismiss",
9219
+ "silence",
9220
+ "view",
9221
+ "play",
9222
+ "open",
9223
+ "close",
9224
+ "lock",
9225
+ "unlock",
9226
+ "arm",
9227
+ "disarm",
9228
+ "light",
9229
+ "alert"
9230
+ ]);
9231
+ /** A single tap-through action button. */
9232
+ var NotificationActionSchema = object({
9233
+ id: string(),
9234
+ label: string(),
9235
+ url: string().optional(),
9236
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
9237
+ icon: NotificationActionIconSchema.optional(),
9238
+ /**
9239
+ * Renders in a warning style where the notifier supports it.
9240
+ *
9241
+ * A HINT, never a gate. The callback's authority is its token and nothing
9242
+ * else — see `notification-center/action-token.ts` for what that does and
9243
+ * does not buy.
9244
+ */
9245
+ destructive: boolean().optional()
9246
+ });
9247
+ /**
9248
+ * The canonical notification. `body` is the only hard field (Apprise model).
9249
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
9250
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
9251
+ * the adapter maps this ordinal onto its native level. `level?` is an
9252
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
9253
+ * `priority` for that one target.
9254
+ */
9255
+ var NotificationSchema = object({
9256
+ body: string(),
9257
+ title: string().optional(),
9258
+ format: NotificationFormatSchema.default("text"),
9259
+ priority: number().int().min(1).max(5).default(3),
9260
+ level: string().optional(),
9261
+ attachments: array(AttachmentSchema).optional(),
9262
+ clickUrl: string().optional(),
9263
+ actions: array(NotificationActionSchema).optional(),
9264
+ sound: string().optional(),
9265
+ ttl: number().optional(),
9266
+ tag: string().optional(),
9267
+ deviceId: number().optional(),
9268
+ eventId: string().optional(),
9269
+ metadata: record(string(), unknown()).optional()
9270
+ });
9271
+ /** One declared native severity/priority level for a kind. */
9272
+ var TargetKindLevelSchema = object({
9273
+ id: string(),
9274
+ label: string(),
9275
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
9276
+ ordinal: number().int().min(1).max(5).nullable(),
9277
+ flags: object({
9278
+ critical: boolean().optional(),
9279
+ silent: boolean().optional(),
9280
+ noPush: boolean().optional()
9281
+ }).optional(),
9282
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
9283
+ requires: array(string()).optional(),
9284
+ description: string().optional()
9285
+ });
9286
+ /** The full capability block consulted before dispatch. */
9287
+ var TargetKindCapsSchema = object({
9288
+ attachments: object({
9289
+ mediaTypes: array(AttachmentMediaTypeSchema),
9290
+ mode: _enum([
9291
+ "url",
9292
+ "bytes",
9293
+ "both"
9294
+ ]),
9295
+ max: number().int().nonnegative(),
9296
+ maxBytes: number().int().positive().optional()
9297
+ }),
9298
+ /** Max action buttons (0 = none). */
9299
+ actions: number().int().nonnegative(),
9300
+ /**
9301
+ * Whether this kind renders a per-action ICON.
9302
+ *
9303
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
9304
+ * run on the addon cap path — three production failures in one day taught
9305
+ * this repo that once. Absent is read as false by the degrade engine, which
9306
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
9307
+ * assumed and dropped costs the operator's trust in the field.
9308
+ */
9309
+ actionIcons: boolean().optional(),
9310
+ levels: array(TargetKindLevelSchema),
9311
+ format: array(NotificationFormatSchema),
9312
+ clickUrl: boolean(),
9313
+ sound: boolean(),
9314
+ ttl: boolean(),
9315
+ bodyMaxLen: number().int().positive()
9316
+ });
9317
+ /**
9318
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
9319
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
9320
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
9321
+ * the union is large and not meant for runtime validation here; the exported
9322
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
9323
+ */
9324
+ var ConfigSchemaPassthrough$1 = unknown();
9325
+ var TargetKindSchema = object({
9326
+ kind: string(),
9327
+ label: string(),
9328
+ icon: string(),
9329
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
9330
+ addonId: string(),
9331
+ /**
9332
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
9333
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
9334
+ * when the addon bundles no icon for that kind — the client then falls back
9335
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
9336
+ *
9337
+ * Root-relative on purpose: it resolves against whatever origin serves a web
9338
+ * client, and a native client joins it onto its own hub base.
9339
+ *
9340
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
9341
+ * field that survived only because the runtime cap-router forwards provider
9342
+ * output verbatim — so every consumer had to re-declare it by hand to stop
9343
+ * its own Zod parse from stripping it, and the whole arrangement would have
9344
+ * broken silently the moment output validation was tightened anywhere.
9345
+ */
9346
+ iconUrl: string().optional(),
9347
+ /**
9348
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
9349
+ *
9350
+ * The server knows this and therefore says it, because the client cannot
9351
+ * safely guess: a React-Native client renders SVG and raster through two
9352
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
9353
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
9354
+ * placeholder glyph for every vector icon while the web build looked fine.
9355
+ *
9356
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
9357
+ * not been updated — a client that cannot determine the type should prefer
9358
+ * its raster path, which is the safe default for an unknown image.
9359
+ */
9360
+ iconMediaType: string().optional(),
9361
+ configSchema: ConfigSchemaPassthrough$1,
9362
+ supportsDiscovery: boolean(),
9363
+ caps: TargetKindCapsSchema
9364
+ });
9365
+ /**
9366
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
9367
+ * (return a presence marker only) when serving `listTargets` — never
9368
+ * round-trip a stored secret to the UI.
9369
+ */
9370
+ var TargetSchema = object({
9371
+ id: string(),
9372
+ name: string(),
9373
+ kind: string(),
9374
+ addonId: string(),
9375
+ enabled: boolean(),
9376
+ config: record(string(), unknown())
9377
+ });
9378
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
9379
+ var DiscoveredTargetSchema = object({
9380
+ kind: string(),
9381
+ suggestedName: string(),
9382
+ config: record(string(), unknown())
9383
+ });
9384
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
9385
+ var RenderedAsSchema = object({
9386
+ level: string(),
9387
+ format: NotificationFormatSchema,
9388
+ attachmentsSent: number().int().nonnegative(),
9389
+ actionsSent: number().int().nonnegative(),
9390
+ truncated: boolean(),
9391
+ dropped: array(string())
9392
+ });
9393
+ var SendResultSchema = object({
9394
+ success: boolean(),
9395
+ error: string().optional(),
9396
+ renderedAs: RenderedAsSchema.optional()
9397
+ });
9398
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
9399
+ var TestResultSchema = SendResultSchema;
9400
+ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
9401
+ kind: string(),
9402
+ config: record(string(), unknown()).optional()
9403
+ }), array(DiscoveredTargetSchema)), method(object({
9404
+ targetId: string(),
9405
+ notification: NotificationSchema
9406
+ }), SendResultSchema, { kind: "mutation" }), method(object({
9407
+ targetId: string(),
9408
+ sample: NotificationSchema.optional()
9409
+ }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
9410
+ targetId: string(),
9411
+ enabled: boolean()
9412
+ }), _void(), { kind: "mutation" });
9413
+ /**
9065
9414
  * notification-rules — the Notification Center rule surface (P1 core).
9066
9415
  *
9067
9416
  * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
@@ -9191,7 +9540,115 @@ var NcZoneConditionSchema = object({
9191
9540
  * The P1 condition set — a flat AND of groups; absent group = pass;
9192
9541
  * membership lists are OR within the list (spec §2.3).
9193
9542
  */
9543
+ /**
9544
+ * What a rule may actuate.
9545
+ *
9546
+ * **No hand-maintained allowlist** (operator decision, and the right one — a
9547
+ * written list of methods is a third parallel map to keep aligned, and this
9548
+ * repo has paid for those). The boundary instead comes from a property the
9549
+ * capabilities already carry: an action may target only a **device-scoped**
9550
+ * capability method.
9551
+ *
9552
+ * That is not decoration. A rule can be authored by a NON-ADMIN — personal
9553
+ * rules are a supported flow — and the executor runs with the addon's
9554
+ * privileges, so an unbounded action is an arbitrary RPC channel with a
9555
+ * privilege escalation attached. Restricting to device scope excludes the
9556
+ * system caps (`device-manager.removeDevice` and friends) by construction,
9557
+ * costs nothing to maintain, and cannot rot: a cap that stops being
9558
+ * device-scoped stops being actuatable in the same change.
9559
+ *
9560
+ * The executor enforces it; {@link NcRuleActionSchema} carries the intent.
9561
+ */
9562
+ /**
9563
+ * One step of a sequence.
9564
+ *
9565
+ * `wait` is a first-class step rather than a property of the next action: it is
9566
+ * what makes a sequence a SEQUENCE and not a list — "unlock, wait 5s, open"
9567
+ * cannot be expressed otherwise.
9568
+ */
9569
+ var NcRuleActionSchema = discriminatedUnion("kind", [object({
9570
+ kind: literal("wait"),
9571
+ seconds: number().min(0).max(300)
9572
+ }), object({
9573
+ kind: literal("cap"),
9574
+ deviceId: number().int(),
9575
+ /** Capability name, e.g. `alarm-panel`. */
9576
+ cap: string().min(1),
9577
+ /** Method on it. The executor refuses a non-device-scoped cap. */
9578
+ method: string().min(1),
9579
+ /** Method arguments, minus `deviceId` (the executor injects it). */
9580
+ args: record(string(), unknown()).optional()
9581
+ })]);
9582
+ /**
9583
+ * A named, ordered run of steps with its own throttle.
9584
+ *
9585
+ * `minDelaySec` exists because a noisy rule otherwise hammers a physical
9586
+ * actuator — the rule's own cooldown governs NOTIFICATIONS, which is a
9587
+ * different budget from "how often may this gate actually open".
9588
+ */
9589
+ var NcRuleActionSequenceSchema = object({
9590
+ name: string().min(1).max(120),
9591
+ enabled: boolean(),
9592
+ minDelaySec: number().int().min(0).max(86400).optional(),
9593
+ actions: array(NcRuleActionSchema).min(1)
9594
+ });
9595
+ /**
9596
+ * One button carried by the notification, running a named sequence on tap.
9597
+ *
9598
+ * **Read this before adding a button that does something physical.** The tap
9599
+ * arrives over a link that travelled through third-party infrastructure — ntfy,
9600
+ * a push relay, whatever forwarded the message — and the callback's ONLY
9601
+ * authority is the token in that link: single-use, short-lived, bound to this
9602
+ * one action of this one notification. It does not identify who tapped.
9603
+ * Whoever holds the notification can run the button, once, inside the window.
9604
+ * That is the operator's explicit choice (2026-08-05), and `destructive` is a
9605
+ * rendering hint, not a second gate. [D47](decisions/adr-0047.md).
9606
+ */
9607
+ var NcRuleNotificationButtonSchema = object({
9608
+ /** Stable id — travels in the callback and identifies the button in logs. */
9609
+ id: string().min(1).max(64),
9610
+ label: string().min(1).max(40),
9611
+ /** Name of a sequence in `onTrigger`. The dispatcher drops a button whose
9612
+ * sequence does not exist rather than minting a token for nothing. */
9613
+ sequence: string().min(1).max(120),
9614
+ icon: NotificationActionIconSchema.optional(),
9615
+ destructive: boolean().optional()
9616
+ });
9617
+ /**
9618
+ * Sequences a rule runs, by hook point.
9619
+ *
9620
+ * ONLY `onTrigger` is here, deliberately. The reference also has activation /
9621
+ * deactivation / reset / post-generation hooks, and they are wanted — but this
9622
+ * repo's expensive failure mode is declaring a surface nothing produces, so a
9623
+ * hook appears here in the same change that produces its edge, never before.
9624
+ */
9625
+ var NcRuleActionsSchema = object({
9626
+ /** Runs when the rule MATCHES. */
9627
+ onTrigger: array(NcRuleActionSequenceSchema).optional(),
9628
+ /**
9629
+ * Buttons the NOTIFICATION carries, each running one of this rule's
9630
+ * sequences when tapped.
9631
+ *
9632
+ * Deliberately a REFERENCE to a sequence rather than a second place to
9633
+ * author steps. A button that could define its own actions would be a
9634
+ * parallel actuation vocabulary — the executor's device-scope check, the
9635
+ * stop-at-first-failure rule and the per-sequence throttle all live on
9636
+ * sequences, and a second authoring surface would drift from every one of
9637
+ * them.
9638
+ *
9639
+ * A sequence reachable ONLY by a button simply appears in `onTrigger` with
9640
+ * `enabled: false`: it is then authored, throttled and validated like the
9641
+ * rest, and nothing runs it automatically.
9642
+ */
9643
+ buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
9644
+ });
9194
9645
  var NcConditionsSchema = object({
9646
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
9647
+ deviceState: object({
9648
+ deviceId: number().int(),
9649
+ /** Any of these matches. */
9650
+ states: array(string().min(1)).min(1)
9651
+ }).optional(),
9195
9652
  /** Device scope — absent = all devices. */
9196
9653
  devices: array(number()).optional(),
9197
9654
  /** Detector class names (any overlap with the record's class set). */
@@ -9392,6 +9849,14 @@ var NcMediaPolicySchema = object({
9392
9849
  clipPreRollSec: number().int().min(0).max(30).optional(),
9393
9850
  clipPostRollSec: number().int().min(0).max(30).optional(),
9394
9851
  /**
9852
+ * Playback rate of the attached gif / clip. Absent = 2x.
9853
+ *
9854
+ * A notification clip is GLANCED at on a lock screen, not watched: at real
9855
+ * time an eight-second passage is eight seconds of the recipient's attention
9856
+ * and twice the bytes. 1 is real time for the operator who wants it.
9857
+ */
9858
+ clipSpeed: number().min(1).max(8).optional(),
9859
+ /**
9395
9860
  * Which stream profile the footage is cut from. Absent = the CHEAPEST
9396
9861
  * assigned profile: a notification is watched on a phone, so the 4K
9397
9862
  * rendition would burn CPU to produce a file the client downscales anyway.
@@ -9463,7 +9928,30 @@ var NcRuleInputSchema = object({
9463
9928
  * behaviour, visible to all, read-only in the viewer). Present = personal
9464
9929
  * rule owned by this userId. Server-stamped; never trusted from a client.
9465
9930
  */
9466
- ownerUserId: string().optional()
9931
+ ownerUserId: string().optional(),
9932
+ /**
9933
+ * May a non-admin snooze this rule for EVERYONE, not just themselves?
9934
+ *
9935
+ * A snooze is personal by default — it silences the person who set it. This
9936
+ * opts THIS rule into the "the gardener is here all afternoon" case, where
9937
+ * silencing the camera for the whole household is legitimate. It silences
9938
+ * other people, so it is off unless a rule deliberately allows it.
9939
+ *
9940
+ * `.optional()`, deliberately NOT `.default()`: a Zod default does not run on
9941
+ * the addon cap path (three production failures in one day), so absent is
9942
+ * read as `false` by {@link canSetGlobal} in the engine. Admins are not bound
9943
+ * by this flag — see the scope rules on that function.
9944
+ */
9945
+ snoozeAllowGlobal: boolean().optional(),
9946
+ /**
9947
+ * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
9948
+ *
9949
+ * This is what makes the rule set the alarm's trigger set without the alarm
9950
+ * being a special case: arming is
9951
+ * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
9952
+ * shape as every other actuation.
9953
+ */
9954
+ actions: NcRuleActionsSchema.optional()
9467
9955
  });
9468
9956
  /**
9469
9957
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -9534,7 +10022,8 @@ var NcConditionDescriptorSchema = object({
9534
10022
  "packagePhase",
9535
10023
  "crossingSelect",
9536
10024
  "polygonDraw",
9537
- "occupancy"
10025
+ "occupancy",
10026
+ "deviceState"
9538
10027
  ]),
9539
10028
  operator: _enum([
9540
10029
  "in",
@@ -9548,7 +10037,28 @@ var NcConditionDescriptorSchema = object({
9548
10037
  /** Which delivery kinds the condition applies to. */
9549
10038
  appliesTo: array(NcDeliverySchema),
9550
10039
  phase: string(),
9551
- description: string().optional()
10040
+ description: string().optional(),
10041
+ /**
10042
+ * The CHOICES for a single-choice widget (`sourceSelect`, `crossingSelect`,
10043
+ * `packagePhase`, …), served with the descriptor.
10044
+ *
10045
+ * Before this the descriptor said which widget to render and not what to put
10046
+ * in it, so every option list lived in three places: this file's enums, the
10047
+ * admin's `NC_*_OPTIONS` and the viewer's `NC_*_VALUES`. That triple mirror
10048
+ * is the drift that emptied the viewer's rule editor on 2026-08-04 — the app
10049
+ * mirrors the cap by hand, so it is only ever as current as its last build.
10050
+ *
10051
+ * With the options on the wire, a condition of an EXISTING `valueType` costs
10052
+ * zero client changes. Clients keep a local fallback for an older hub that
10053
+ * does not send them; absent here is "use your own list", not "no choices".
10054
+ */
10055
+ options: array(object({
10056
+ /** Written to the rule verbatim. `''` means the ABSENT state. */
10057
+ value: string(),
10058
+ label: string(),
10059
+ /** What THIS choice matches — shown one at a time, under the control. */
10060
+ hint: string().optional()
10061
+ })).readonly().optional()
9552
10062
  });
9553
10063
  /**
9554
10064
  * The delivery lifecycle status of a history row — a straight read of the
@@ -9633,6 +10143,135 @@ var NcHistoryFilterSchema = object({
9633
10143
  until: number().optional(),
9634
10144
  limit: number().int().min(1).max(500).default(100)
9635
10145
  });
10146
+ /**
10147
+ * What a snooze covers. Broader scopes win when several overlap, so one window
10148
+ * leaves ONE digest rather than a rule snooze and a whole-feed snooze both
10149
+ * summarising the same silence.
10150
+ */
10151
+ var NcSnoozeScopeSchema = _enum([
10152
+ "rule",
10153
+ "device",
10154
+ "all"
10155
+ ]);
10156
+ /**
10157
+ * Client-authored snooze. The server stamps `userId`, `startedAt` and
10158
+ * `expiresAt` — a DURATION is sent rather than an instant so a client with a
10159
+ * skewed clock cannot author a window that is already over, or never ends.
10160
+ */
10161
+ var NcSnoozeInputSchema = object({
10162
+ scope: NcSnoozeScopeSchema,
10163
+ /** Required when `scope: 'rule'` — a scoped snooze with no id matches
10164
+ * NOTHING rather than degrading to "everything". */
10165
+ ruleId: string().optional(),
10166
+ /** Required when `scope: 'device'`. */
10167
+ deviceId: number().int().optional(),
10168
+ durationMinutes: number().int().min(1).max(1440),
10169
+ /**
10170
+ * Silence this for EVERY recipient, not just the caller. Permission is
10171
+ * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
10172
+ * broader scopes). Absent = personal.
10173
+ */
10174
+ global: boolean().optional(),
10175
+ /**
10176
+ * Deliver a summary of what was suppressed when the window ends. Absent =
10177
+ * ON: someone silencing a nuisance camera wants it off, someone silencing a
10178
+ * SECURITY camera wants to know what they missed, and choosing "off" for
10179
+ * everybody is how a snooze becomes an outage. Resolved to a concrete
10180
+ * boolean by the server at create time — never left to a Zod default, which
10181
+ * does not run on the addon cap path.
10182
+ */
10183
+ summary: boolean().optional()
10184
+ });
10185
+ /** A persisted snooze window. */
10186
+ var NcSnoozeSchema = object({
10187
+ id: string(),
10188
+ /** Who set it. Also who it silences, unless `global`. */
10189
+ userId: string(),
10190
+ scope: NcSnoozeScopeSchema,
10191
+ ruleId: string().optional(),
10192
+ deviceId: number().int().optional(),
10193
+ startedAt: number(),
10194
+ /** Exclusive: at exactly this instant the snooze is over. Expiry is a
10195
+ * COMPARISON, not a job — no sweeper can leave the operator silenced. */
10196
+ expiresAt: number(),
10197
+ global: boolean(),
10198
+ summary: boolean(),
10199
+ /** When the end-of-window digest went out. Absent = not sent (yet, or the
10200
+ * window has not closed, or `summary` is false). */
10201
+ digestSentAt: number().optional()
10202
+ });
10203
+ object({
10204
+ snoozeId: string(),
10205
+ targetId: string(),
10206
+ ruleId: string(),
10207
+ ruleName: string(),
10208
+ deviceId: number().int(),
10209
+ /** How many notifications this snooze hid for that pair. */
10210
+ count: number().int(),
10211
+ firstAt: number(),
10212
+ lastAt: number()
10213
+ });
10214
+ /**
10215
+ * The three durations the panel's state machine runs on, plus who hears about
10216
+ * an arm.
10217
+ *
10218
+ * They live on the NOTIFICATION-RULES cap, not on `alarm-panel`, on purpose:
10219
+ * `alarm-panel` is `deviceNative` and its other provider mirrors somebody
10220
+ * else's panel, which has its own delays and no way to be told these. This is
10221
+ * configuration of the panel CamStack owns, and the Notification Center owns
10222
+ * that panel.
10223
+ */
10224
+ var NcAlarmSettingsSchema = object({
10225
+ /** Grace period between arming and the mode taking effect. 0 = immediate. */
10226
+ exitDelaySec: number().int().min(0).max(600),
10227
+ /** Grace period between a trigger and the alarm firing. 0 = immediate. */
10228
+ entryDelaySec: number().int().min(0).max(600),
10229
+ /**
10230
+ * How long `triggered` lasts before the panel re-arms itself.
10231
+ * **0 = until an operator disarms it** — the behaviour before this field
10232
+ * existed, and therefore what an untouched install keeps doing.
10233
+ */
10234
+ triggeredDurationSec: number().int().min(0).max(3600),
10235
+ /** Send a notification when a mode takes effect. */
10236
+ announceArm: boolean(),
10237
+ /**
10238
+ * Where that notification goes. Target ids from `notification-output`.
10239
+ *
10240
+ * Explicit rather than "everyone": an arm announcement is a household
10241
+ * message, and broadcasting it to every configured endpoint (including a
10242
+ * webhook wired to something else) is not a default anybody would choose.
10243
+ * Empty with `announceArm: true` sends nothing, and the server logs that —
10244
+ * silence must be attributable.
10245
+ */
10246
+ announceTargets: array(string().min(1)).max(16)
10247
+ });
10248
+ /** Every field optional — a tab edits one control at a time. */
10249
+ var NcAlarmSettingsPatchSchema = NcAlarmSettingsSchema.partial();
10250
+ /**
10251
+ * What one arm mode actually arms, DERIVED from the enabled rules gated on it.
10252
+ * Never authored, never stored — see `alarm-mode-coverage.ts` for why a stored
10253
+ * copy would lie the first time a rule is disabled.
10254
+ */
10255
+ var NcAlarmModeCoverageSchema = object({
10256
+ mode: AlarmArmModeSchema,
10257
+ /** Enabled rules gated on `armed_<mode>`. Zero means the mode does nothing. */
10258
+ ruleCount: number().int().min(0),
10259
+ /** At least one covering rule has no device scope, so the mode covers all. */
10260
+ allDevices: boolean(),
10261
+ /** Ids named by the covering rules. A SUBSET when `allDevices` is true. */
10262
+ deviceIds: array(number().int())
10263
+ });
10264
+ var NcAlarmConfigSchema = object({
10265
+ /**
10266
+ * The panel's device id, or null when this install has no panel (the ensure
10267
+ * step failed, or this is not the hub). Null is the honest answer: an editor
10268
+ * that rendered delays for a panel that does not exist would be a form whose
10269
+ * Save does nothing.
10270
+ */
10271
+ deviceId: number().int().nullable(),
10272
+ settings: NcAlarmSettingsSchema,
10273
+ coverage: array(NcAlarmModeCoverageSchema)
10274
+ });
9636
10275
  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 }), {
9637
10276
  kind: "mutation",
9638
10277
  auth: "admin",
@@ -9662,7 +10301,16 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
9662
10301
  }), method(object({}), object({
9663
10302
  catalog: array(NcConditionDescriptorSchema),
9664
10303
  taxonomy: NcTaxonomySchema.optional()
9665
- })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
10304
+ })), 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 }), {
10305
+ kind: "mutation",
10306
+ caller: "required"
10307
+ }), method(object({ snoozeId: string() }), object({ success: literal(true) }), {
10308
+ kind: "mutation",
10309
+ caller: "required"
10310
+ }), method(object({}), NcAlarmConfigSchema, { auth: "admin" }), method(object({ patch: NcAlarmSettingsPatchSchema }), NcAlarmConfigSchema, {
10311
+ kind: "mutation",
10312
+ auth: "admin"
10313
+ });
9666
10314
  /**
9667
10315
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
9668
10316
  *
@@ -9870,72 +10518,6 @@ object({
9870
10518
  precision: number().int().min(0).max(10).optional()
9871
10519
  });
9872
10520
  DeviceType.Sensor;
9873
- /**
9874
- * Alarm-panel cap. Models HA `alarm_control_panel.*` on
9875
- * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
9876
- * across disarmed / armed_(home|away|night|vacation|custom_bypass) /
9877
- * arming / pending / triggered / disarming.
9878
- *
9879
- * Many panels require a PIN code on arm / disarm — the optional
9880
- * `code` field on the methods passes it through to the upstream
9881
- * service; it's NEVER persisted in the runtime slice or any event
9882
- * payload. The presence of a required code is signalled by
9883
- * `DeviceFeature.AlarmPinRequired` so the UI gates a code-entry
9884
- * field without a slice fetch.
9885
- *
9886
- * `availableModes` mirrors HA's `supported_features`-derived arm
9887
- * mode list — the UI renders only the buttons the panel accepts.
9888
- */
9889
- var AlarmStateSchema = _enum([
9890
- "disarmed",
9891
- "armed_home",
9892
- "armed_away",
9893
- "armed_night",
9894
- "armed_vacation",
9895
- "armed_custom_bypass",
9896
- "arming",
9897
- "disarming",
9898
- "pending",
9899
- "triggered"
9900
- ]);
9901
- var AlarmArmModeSchema = _enum([
9902
- "home",
9903
- "away",
9904
- "night",
9905
- "vacation",
9906
- "custom_bypass"
9907
- ]);
9908
- object({
9909
- /** Current lifecycle state. */
9910
- state: AlarmStateSchema,
9911
- /** Subset of arm modes the panel accepts. UI renders one button per
9912
- * mode in this list. */
9913
- availableModes: array(AlarmArmModeSchema),
9914
- /** Whether the panel requires a PIN on arm / disarm. Mirrors
9915
- * `DeviceFeature.AlarmPinRequired` for slice consumers. */
9916
- requiresCode: boolean(),
9917
- /** Ms epoch when the slice was last updated. */
9918
- lastChangedAt: number()
9919
- });
9920
- DeviceType.AlarmPanel, method(object({
9921
- deviceId: number().int().nonnegative(),
9922
- mode: AlarmArmModeSchema,
9923
- /** Optional PIN code. Required when `requiresCode === true`.
9924
- * Passed through to the upstream service; never persisted. */
9925
- code: string().min(1).optional()
9926
- }), _void(), {
9927
- kind: "mutation",
9928
- auth: "admin"
9929
- }), method(object({
9930
- deviceId: number().int().nonnegative(),
9931
- code: string().min(1).optional()
9932
- }), _void(), {
9933
- kind: "mutation",
9934
- auth: "admin"
9935
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
9936
- kind: "mutation",
9937
- auth: "admin"
9938
- });
9939
10521
  object({
9940
10522
  /** Current illuminance in lux (lx). */
9941
10523
  lux: number().min(0),
@@ -10410,7 +10992,15 @@ method(object({
10410
10992
  format: _enum(["gif", "mp4"]).default("gif"),
10411
10993
  maxWidth: number().int().min(120).max(1920).default(480),
10412
10994
  /** GIF only — MP4 keeps the source cadence. */
10413
- fps: number().int().min(1).max(15).default(5)
10995
+ fps: number().int().min(1).max(15).default(5),
10996
+ /**
10997
+ * Playback rate. A notification clip is GLANCED at on a lock screen,
10998
+ * not watched, so 2x is the default: the recipient sees the whole
10999
+ * passage in half the time and the GIF is half the bytes. `1` is real
11000
+ * time. Applies to MP4 as well — the operator set a speed, not a GIF
11001
+ * speed.
11002
+ */
11003
+ speed: number().min(1).max(8).default(2)
10414
11004
  }), object({
10415
11005
  base64: string(),
10416
11006
  mime: string(),
@@ -14992,6 +15582,20 @@ var GetInputSchema = object({ id: string() });
14992
15582
  var AddInputSchema = object({
14993
15583
  kind: string().min(1),
14994
15584
  name: string().min(1),
15585
+ /**
15586
+ * ADOPT an existing id instead of minting a new one.
15587
+ *
15588
+ * Written the day this cost an outage. A broker's id is not a detail: HA
15589
+ * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
15590
+ * lost from config and re-added as `ha_001` leaves every one of its devices
15591
+ * bound to a broker that no longer exists. Re-entering the password under the
15592
+ * ORIGINAL id turns a multi-step device migration back into re-entering a
15593
+ * password.
15594
+ *
15595
+ * A provider MUST refuse an id that is already in use — adopting a live
15596
+ * broker's id would silently take it over.
15597
+ */
15598
+ id: string().min(1).optional(),
14995
15599
  /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
14996
15600
  * `{baseUrl,accessToken}`). Validated by the kind-specific provider
14997
15601
  * branch on receipt — invalid shape rejects the add. */
@@ -15155,6 +15759,210 @@ method(object({ codec: string() }), boolean()), method(_void(), object({
15155
15759
  kind: "mutation",
15156
15760
  auth: "admin"
15157
15761
  });
15762
+ /**
15763
+ * Query filter for settings-store collections.
15764
+ */
15765
+ var QueryFilterSchema = object({
15766
+ where: record(string(), unknown()).optional(),
15767
+ whereIn: record(string(), array(unknown())).optional(),
15768
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
15769
+ orderBy: object({
15770
+ field: string(),
15771
+ direction: _enum(["asc", "desc"])
15772
+ }).optional(),
15773
+ limit: number().optional(),
15774
+ offset: number().optional()
15775
+ });
15776
+ /**
15777
+ * The predicate half of a filter, for BULK MUTATIONS.
15778
+ *
15779
+ * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
15780
+ * meaning for a statement that rewrites a set, and accepting them would invite
15781
+ * a caller to believe `limit` bounds the damage. Every field is optional here
15782
+ * only so the shape stays composable — the implementation REJECTS a filter
15783
+ * that compiles to no predicate, because that is the whole collection.
15784
+ */
15785
+ var MutationFilterSchema = object({
15786
+ where: record(string(), unknown()).optional(),
15787
+ whereIn: record(string(), array(unknown())).optional(),
15788
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional()
15789
+ });
15790
+ /** A single stored record: `{ id, data }`. */
15791
+ var SettingsRecordSchema = object({
15792
+ id: string(),
15793
+ data: record(string(), unknown())
15794
+ });
15795
+ /**
15796
+ * Column declaration for a structured (SQL-backed) collection.
15797
+ *
15798
+ * Logical types — the backend translates each to the matching SQLite
15799
+ * storage class and handles per-type marshaling:
15800
+ * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
15801
+ * - `JSON` — TEXT under the hood; serialised on write, parsed on read
15802
+ * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
15803
+ */
15804
+ var CollectionColumnSchema = object({
15805
+ name: string(),
15806
+ type: _enum([
15807
+ "TEXT",
15808
+ "INTEGER",
15809
+ "REAL",
15810
+ "JSON",
15811
+ "BOOLEAN"
15812
+ ]),
15813
+ primaryKey: boolean().optional(),
15814
+ notNull: boolean().optional(),
15815
+ unique: boolean().optional(),
15816
+ /**
15817
+ * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
15818
+ * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
15819
+ * collection surface simply cannot express their existing tables — which is
15820
+ * why they were still on the uncapped `table*` API.
15821
+ */
15822
+ defaultValue: union([
15823
+ string(),
15824
+ number(),
15825
+ boolean()
15826
+ ]).optional()
15827
+ });
15828
+ var CollectionIndexSchema = object({
15829
+ name: string(),
15830
+ columns: array(string()).readonly(),
15831
+ unique: boolean().optional()
15832
+ });
15833
+ method(object({
15834
+ namespace: string().optional(),
15835
+ collection: string(),
15836
+ key: string()
15837
+ }), unknown()), method(object({
15838
+ namespace: string().optional(),
15839
+ collection: string(),
15840
+ key: string(),
15841
+ value: unknown()
15842
+ }), _void(), { kind: "mutation" }), method(object({
15843
+ namespace: string().optional(),
15844
+ collection: string(),
15845
+ filter: QueryFilterSchema.optional()
15846
+ }), array(SettingsRecordSchema).readonly()), method(object({
15847
+ namespace: string().optional(),
15848
+ collection: string(),
15849
+ record: SettingsRecordSchema
15850
+ }), _void(), { kind: "mutation" }), method(object({
15851
+ namespace: string().optional(),
15852
+ collection: string(),
15853
+ id: string(),
15854
+ data: record(string(), unknown())
15855
+ }), _void(), { kind: "mutation" }), method(object({
15856
+ namespace: string().optional(),
15857
+ collection: string(),
15858
+ key: string()
15859
+ }), _void(), { kind: "mutation" }), method(object({
15860
+ namespace: string().optional(),
15861
+ collection: string(),
15862
+ filter: MutationFilterSchema
15863
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
15864
+ namespace: string().optional(),
15865
+ collection: string(),
15866
+ filter: MutationFilterSchema,
15867
+ data: record(string(), unknown())
15868
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
15869
+ namespace: string().optional(),
15870
+ collection: string(),
15871
+ filter: QueryFilterSchema.optional()
15872
+ }), number()), method(object({
15873
+ namespace: string().optional(),
15874
+ collection: string(),
15875
+ field: string(),
15876
+ bucketSize: number().int().positive(),
15877
+ origin: number().int(),
15878
+ filter: QueryFilterSchema.optional()
15879
+ }), array(object({
15880
+ bucket: number().int(),
15881
+ count: number().int()
15882
+ })).readonly()), method(object({
15883
+ namespace: string().optional(),
15884
+ collection: string()
15885
+ }), boolean()), method(object({
15886
+ namespace: string().optional(),
15887
+ collection: string(),
15888
+ columns: array(CollectionColumnSchema).readonly(),
15889
+ indexes: array(CollectionIndexSchema).readonly().optional()
15890
+ }), _void(), { kind: "mutation" });
15891
+ /**
15892
+ * What one engine says about itself. The orchestrator uses `kind` to pick
15893
+ * a registrant for a collection; `engineId` is what a log line names when
15894
+ * a call is routed or refused.
15895
+ */
15896
+ var EngineInfoSchema = object({
15897
+ engineId: string(),
15898
+ /**
15899
+ * `relational` — rows, columns, indexes, the surface `settings-store`
15900
+ * has always described. `vector` — an embedding store answering
15901
+ * similarity queries. A registrant declares exactly one; an engine that
15902
+ * does both registers twice, because "both" would make the routing
15903
+ * decision ambiguous at exactly the point it must not be.
15904
+ */
15905
+ kind: _enum(["relational", "vector"]),
15906
+ displayName: string()
15907
+ });
15908
+ method(_void(), EngineInfoSchema), method(object({
15909
+ namespace: string().optional(),
15910
+ collection: string(),
15911
+ key: string()
15912
+ }), unknown()), method(object({
15913
+ namespace: string().optional(),
15914
+ collection: string(),
15915
+ key: string(),
15916
+ value: unknown()
15917
+ }), _void(), { kind: "mutation" }), method(object({
15918
+ namespace: string().optional(),
15919
+ collection: string(),
15920
+ filter: QueryFilterSchema.optional()
15921
+ }), array(SettingsRecordSchema).readonly()), method(object({
15922
+ namespace: string().optional(),
15923
+ collection: string(),
15924
+ record: SettingsRecordSchema
15925
+ }), _void(), { kind: "mutation" }), method(object({
15926
+ namespace: string().optional(),
15927
+ collection: string(),
15928
+ id: string(),
15929
+ data: record(string(), unknown())
15930
+ }), _void(), { kind: "mutation" }), method(object({
15931
+ namespace: string().optional(),
15932
+ collection: string(),
15933
+ key: string()
15934
+ }), _void(), { kind: "mutation" }), method(object({
15935
+ namespace: string().optional(),
15936
+ collection: string(),
15937
+ filter: MutationFilterSchema
15938
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
15939
+ namespace: string().optional(),
15940
+ collection: string(),
15941
+ filter: MutationFilterSchema,
15942
+ data: record(string(), unknown())
15943
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
15944
+ namespace: string().optional(),
15945
+ collection: string(),
15946
+ filter: QueryFilterSchema.optional()
15947
+ }), number()), method(object({
15948
+ namespace: string().optional(),
15949
+ collection: string(),
15950
+ field: string(),
15951
+ bucketSize: number().int().positive(),
15952
+ origin: number().int(),
15953
+ filter: QueryFilterSchema.optional()
15954
+ }), array(object({
15955
+ bucket: number().int(),
15956
+ count: number().int()
15957
+ })).readonly()), method(object({
15958
+ namespace: string().optional(),
15959
+ collection: string()
15960
+ }), boolean()), method(object({
15961
+ namespace: string().optional(),
15962
+ collection: string(),
15963
+ columns: array(CollectionColumnSchema).readonly(),
15964
+ indexes: array(CollectionIndexSchema).readonly().optional()
15965
+ }), _void(), { kind: "mutation" });
15158
15966
  DeviceType.Camera;
15159
15967
  /**
15160
15968
  * `device-adoption` — generic discovery + adoption surface,
@@ -15858,7 +16666,20 @@ method(object({
15858
16666
  }), _void(), {
15859
16667
  kind: "mutation",
15860
16668
  auth: "admin"
15861
- }), 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({
16669
+ }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({
16670
+ addonId: string().optional(),
16671
+ /**
16672
+ * `slim` omits `config` (returned `{}`), `metadata` (null) and the
16673
+ * `sourceInfo` derived from config — and skips the per-device settings
16674
+ * read that produces them. Everything identifying a device (id, name,
16675
+ * type, online, features, isCamera, parent/link ids) is unchanged.
16676
+ * Do not use it for dispatch routing, which needs `sourceInfo`.
16677
+ */
16678
+ projection: _enum(["full", "slim"]).optional(),
16679
+ /** Return only camera devices. Filtering server-side instead of
16680
+ * shipping 293 rows to find 12. */
16681
+ isCamera: boolean().optional()
16682
+ }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
15862
16683
  mode: DeviceLinkModeSchema,
15863
16684
  devices: array(LinkedDeviceSchema)
15864
16685
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
@@ -16297,14 +17118,14 @@ var LlmProfileSchema = object({
16297
17118
  /** ConfigUISchema tree passed through untyped on the wire (the
16298
17119
  * notification-output `ConfigSchemaPassthrough` precedent at
16299
17120
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
16300
- var ConfigSchemaPassthrough$1 = unknown();
17121
+ var ConfigSchemaPassthrough = unknown();
16301
17122
  var LlmProfileKindDescriptorSchema = object({
16302
17123
  kind: LlmProfileKindSchema,
16303
17124
  label: string(),
16304
17125
  icon: string(),
16305
17126
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
16306
17127
  addonId: string(),
16307
- configSchema: ConfigSchemaPassthrough$1
17128
+ configSchema: ConfigSchemaPassthrough
16308
17129
  });
16309
17130
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
16310
17131
  var LlmDefaultSchema = object({
@@ -16835,227 +17656,102 @@ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
16835
17656
  });
16836
17657
  method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
16837
17658
  /**
16838
- * notification-outputcanonical, capability-gated notification delivery.
16839
- *
16840
- * Apprise-derived model (see
16841
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
16842
- * callers emit ONE canonical `Notification`; each provider declares a
16843
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
16844
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
16845
- * message to what the kind supports callers never special-case a service.
16846
- *
16847
- * DESIGN DECISIONS (locked):
16848
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
16849
- * `setTargetEnabled`), each provider persisting via the `settings-store`
16850
- * cap. Rationale: the admin UI needs one uniform surface across the
16851
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
16852
- * alternative would fork the UI per addon and cannot host the
16853
- * discovery→adopt flow.
16854
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
16855
- * the generated cap-mount auto-`concatCollection`-fans them across every
16856
- * registered provider (notifiers addon + HA addon) so one catalog is
16857
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
16858
- * `addonId` the generated collection router extracts from the call input.
16859
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
16860
- * transformer) + UDS MsgPack both round-trip typed arrays already used by
16861
- * `storage` / `storage-provider` / `recording` caps over the same path. No
16862
- * base64 fallback needed.
16863
- *
16864
- * TODO (deferred, closed-set change — separate decision): add
16865
- * `providerKind: 'notify'` so notification providers surface on the unified
16866
- * admin "Integrations" page.
16867
- */
16868
- /**
16869
- * Zentik-derived typed-media enum — the superset across every kind. Each
16870
- * adapter picks what it supports and the degrade engine filters the rest.
16871
- */
16872
- var AttachmentMediaTypeSchema = _enum([
16873
- "image",
16874
- "video",
16875
- "gif",
16876
- "audio",
16877
- "icon"
16878
- ]);
16879
- /**
16880
- * A single attachment. Exactly one of `url` (remote source, most adapters
16881
- * prefer this) or `bytes` (inline source; required for Pushover-style
16882
- * bytes-only kinds) MUST be present — the degrade engine expresses a
16883
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
16884
- */
16885
- var AttachmentSchema = object({
16886
- mediaType: AttachmentMediaTypeSchema,
16887
- url: string().optional(),
16888
- bytes: _instanceof(Uint8Array).optional(),
16889
- mime: string().optional(),
16890
- name: string().optional()
16891
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
16892
- var NotificationFormatSchema = _enum([
16893
- "text",
16894
- "markdown",
16895
- "html"
17659
+ * core-blocksuser-authored TypeScript, stored in the kernel and executed in
17660
+ * its own process.
17661
+ *
17662
+ * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
17663
+ *
17664
+ * The first use is **owning devices without being a device provider**: a block
17665
+ * declares devices under a system or custom integration and drives their state,
17666
+ * with the same `ctx` an addon gets. Automations come later; nothing here
17667
+ * models a trigger.
17668
+ *
17669
+ * **Stated plainly, because it does not change by being true:** a block has an
17670
+ * addon's powers devices, storage, the event bus, `ctx.api`. It is a plugin
17671
+ * with no review step. What makes that survivable is not a sandbox, it is
17672
+ * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
17673
+ * so a block that throws or never returns is marked `failed` and visible
17674
+ * instead of taking the hub with it (D6). Every method here is admin-only, and
17675
+ * must stay so.
17676
+ */
17677
+ /** Where a block runs. The operator chooses a block driving a device on an
17678
+ * agent is the reason placement is not fixed to the hub. */
17679
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
17680
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
17681
+ * a failing block reads the same way a failing addon does. */
17682
+ var CoreBlockStatusSchema = _enum([
17683
+ "stopped",
17684
+ "starting",
17685
+ "running",
17686
+ "failed"
16896
17687
  ]);
16897
- /** A single tap-through action button. */
16898
- var NotificationActionSchema = object({
16899
- id: string(),
16900
- label: string(),
16901
- url: string().optional()
16902
- });
16903
- /**
16904
- * The canonical notification. `body` is the only hard field (Apprise model).
16905
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
16906
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
16907
- * the adapter maps this ordinal onto its native level. `level?` is an
16908
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
16909
- * `priority` for that one target.
16910
- */
16911
- var NotificationSchema = object({
16912
- body: string(),
16913
- title: string().optional(),
16914
- format: NotificationFormatSchema.default("text"),
16915
- priority: number().int().min(1).max(5).default(3),
16916
- level: string().optional(),
16917
- attachments: array(AttachmentSchema).optional(),
16918
- clickUrl: string().optional(),
16919
- actions: array(NotificationActionSchema).optional(),
16920
- sound: string().optional(),
16921
- ttl: number().optional(),
16922
- tag: string().optional(),
16923
- deviceId: number().optional(),
16924
- eventId: string().optional(),
16925
- metadata: record(string(), unknown()).optional()
16926
- });
16927
- /** One declared native severity/priority level for a kind. */
16928
- var TargetKindLevelSchema = object({
16929
- id: string(),
16930
- label: string(),
16931
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
16932
- ordinal: number().int().min(1).max(5).nullable(),
16933
- flags: object({
16934
- critical: boolean().optional(),
16935
- silent: boolean().optional(),
16936
- noPush: boolean().optional()
16937
- }).optional(),
16938
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
16939
- requires: array(string()).optional(),
16940
- description: string().optional()
16941
- });
16942
- /** The full capability block consulted before dispatch. */
16943
- var TargetKindCapsSchema = object({
16944
- attachments: object({
16945
- mediaTypes: array(AttachmentMediaTypeSchema),
16946
- mode: _enum([
16947
- "url",
16948
- "bytes",
16949
- "both"
16950
- ]),
16951
- max: number().int().nonnegative(),
16952
- maxBytes: number().int().positive().optional()
16953
- }),
16954
- /** Max action buttons (0 = none). */
16955
- actions: number().int().nonnegative(),
16956
- levels: array(TargetKindLevelSchema),
16957
- format: array(NotificationFormatSchema),
16958
- clickUrl: boolean(),
16959
- sound: boolean(),
16960
- ttl: boolean(),
16961
- bodyMaxLen: number().int().positive()
16962
- });
16963
- /**
16964
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
16965
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
16966
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
16967
- * the union is large and not meant for runtime validation here; the exported
16968
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
16969
- */
16970
- var ConfigSchemaPassthrough = unknown();
16971
- var TargetKindSchema = object({
16972
- kind: string(),
16973
- label: string(),
16974
- icon: string(),
16975
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
16976
- addonId: string(),
16977
- /**
16978
- * URL of the kind's bundled BRAND icon, served by the providing addon over
16979
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
16980
- * when the addon bundles no icon for that kind — the client then falls back
16981
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
16982
- *
16983
- * Root-relative on purpose: it resolves against whatever origin serves a web
16984
- * client, and a native client joins it onto its own hub base.
16985
- *
16986
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
16987
- * field that survived only because the runtime cap-router forwards provider
16988
- * output verbatim — so every consumer had to re-declare it by hand to stop
16989
- * its own Zod parse from stripping it, and the whole arrangement would have
16990
- * broken silently the moment output validation was tightened anywhere.
16991
- */
16992
- iconUrl: string().optional(),
17688
+ /** Client-authored fields. */
17689
+ var CoreBlockInputSchema = object({
17690
+ name: string().min(1).max(120),
17691
+ /** TypeScript source. Compiled server-side before it is ever stored — a
17692
+ * block that does not compile is a fork failure the operator would meet
17693
+ * minutes later, in a log, instead of in the editor. */
17694
+ code: string().max(2e5),
17695
+ enabled: boolean(),
17696
+ placement: CoreBlockPlacementSchema,
16993
17697
  /**
16994
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
16995
- *
16996
- * The server knows this and therefore says it, because the client cannot
16997
- * safely guess: a React-Native client renders SVG and raster through two
16998
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
16999
- * not decode SVG on iOS/Android), so without this it silently fell back to a
17000
- * placeholder glyph for every vector icon while the web build looked fine.
17001
- *
17002
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
17003
- * not been updated — a client that cannot determine the type should prefer
17004
- * its raster path, which is the safe default for an unknown image.
17698
+ * Integration the block's devices hang from. Absent = the system integration
17699
+ * blocks share. A block may declare its own instead.
17005
17700
  */
17006
- iconMediaType: string().optional(),
17007
- configSchema: ConfigSchemaPassthrough,
17008
- supportsDiscovery: boolean(),
17009
- caps: TargetKindCapsSchema
17701
+ integrationId: string().optional()
17010
17702
  });
17011
- /**
17012
- * A persisted target. `config` holds secrets; providers REDACT secret fields
17013
- * (return a presence marker only) when serving `listTargets` — never
17014
- * round-trip a stored secret to the UI.
17015
- */
17016
- var TargetSchema = object({
17703
+ /** A stored block. */
17704
+ var CoreBlockSchema = CoreBlockInputSchema.extend({
17017
17705
  id: string(),
17018
- name: string(),
17019
- kind: string(),
17020
- addonId: string(),
17021
- enabled: boolean(),
17022
- config: record(string(), unknown())
17023
- });
17024
- /** A discovery-surfaced candidate (config is partial + non-secret). */
17025
- var DiscoveredTargetSchema = object({
17026
- kind: string(),
17027
- suggestedName: string(),
17028
- config: record(string(), unknown())
17029
- });
17030
- /** The degrade engine's report — what was resolved / dropped / degraded. */
17031
- var RenderedAsSchema = object({
17032
- level: string(),
17033
- format: NotificationFormatSchema,
17034
- attachmentsSent: number().int().nonnegative(),
17035
- actionsSent: number().int().nonnegative(),
17036
- truncated: boolean(),
17037
- dropped: array(string())
17706
+ createdAt: number(),
17707
+ updatedAt: number(),
17708
+ /** Server-stamped author. */
17709
+ createdBy: string(),
17710
+ status: CoreBlockStatusSchema,
17711
+ /**
17712
+ * Why the block is not running, when it is not. The operator's ONLY window
17713
+ * into a block that failed at load — a block that is silently absent is the
17714
+ * failure mode this whole feature has to avoid.
17715
+ */
17716
+ lastError: string().optional(),
17717
+ /** Ms epoch of the last state change. */
17718
+ lastChangedAt: number()
17038
17719
  });
17039
- var SendResultSchema = object({
17040
- success: boolean(),
17720
+ /** What a compile attempt produced. */
17721
+ var CoreBlockCompileResultSchema = object({
17722
+ ok: boolean(),
17723
+ /** Present when `ok` is false — the first error, in the author's words. */
17041
17724
  error: string().optional(),
17042
- renderedAs: RenderedAsSchema.optional()
17725
+ line: number().optional(),
17726
+ column: number().optional()
17043
17727
  });
17044
- /** Same shape as SendResult kept as a distinct name for the test panel. */
17045
- var TestResultSchema = SendResultSchema;
17046
- method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
17047
- kind: string(),
17048
- config: record(string(), unknown()).optional()
17049
- }), array(DiscoveredTargetSchema)), method(object({
17050
- targetId: string(),
17051
- notification: NotificationSchema
17052
- }), SendResultSchema, { kind: "mutation" }), method(object({
17053
- targetId: string(),
17054
- sample: NotificationSchema.optional()
17055
- }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
17056
- targetId: string(),
17728
+ 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 }), {
17729
+ kind: "mutation",
17730
+ auth: "admin",
17731
+ caller: "required"
17732
+ }), method(object({
17733
+ blockId: string(),
17734
+ block: CoreBlockInputSchema.partial()
17735
+ }), object({ block: CoreBlockSchema }), {
17736
+ kind: "mutation",
17737
+ auth: "admin",
17738
+ caller: "required"
17739
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
17740
+ kind: "mutation",
17741
+ auth: "admin"
17742
+ }), method(object({
17743
+ blockId: string(),
17057
17744
  enabled: boolean()
17058
- }), _void(), { kind: "mutation" });
17745
+ }), object({ block: CoreBlockSchema }), {
17746
+ kind: "mutation",
17747
+ auth: "admin"
17748
+ }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
17749
+ kind: "mutation",
17750
+ auth: "admin"
17751
+ }), method(object({}), object({ libs: array(object({
17752
+ filePath: string(),
17753
+ content: string()
17754
+ })) }), { auth: "admin" });
17059
17755
  /**
17060
17756
  * Zod schemas for persisted record types.
17061
17757
  *
@@ -17303,6 +17999,11 @@ var EventKindDescriptorSchema = object({
17303
17999
  deviceId: number()
17304
18000
  })
17305
18001
  });
18002
+ /** One camera's event vocabulary, as returned by `listEventKindsBatch`. */
18003
+ var EventKindsForDeviceSchema = object({
18004
+ deviceId: number(),
18005
+ kinds: array(EventKindDescriptorSchema).readonly()
18006
+ });
17306
18007
  var SensorEventSchema = object({
17307
18008
  id: string(),
17308
18009
  /** The CAMERA the event is attributed to (a sensor linked to N cameras
@@ -17559,6 +18260,19 @@ var MediaFileSchema = object({
17559
18260
  sizeBytes: number(),
17560
18261
  timestamp: number()
17561
18262
  });
18263
+ /**
18264
+ * One media row WITHOUT its bytes.
18265
+ *
18266
+ * A track's media is 5-8 MB of base64 (measured: 7.67 MB across 23 files for a
18267
+ * 140 s track), and a client that renders tiles from the media data plane needs
18268
+ * to know only WHAT EXISTS — the bytes then arrive per tile, lazily, over HTTP
18269
+ * with an immutable cache, instead of all at once inside a tRPC response that
18270
+ * blocks the whole view.
18271
+ *
18272
+ * `sizeBytes` is carried because it is what lets a client decide between the
18273
+ * stored blob and a `?variant=thumb` rendering without fetching either.
18274
+ */
18275
+ var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
17562
18276
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
17563
18277
  var MAX_EVENT_QUERY_LIMIT = 5e3;
17564
18278
  var DeviceEventQueryInput = object({
@@ -17708,7 +18422,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17708
18422
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
17709
18423
  kind: "mutation",
17710
18424
  auth: "admin"
17711
- }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({
18425
+ }), 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({
17712
18426
  deviceId: number(),
17713
18427
  since: number().optional(),
17714
18428
  until: number().optional(),
@@ -17782,7 +18496,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17782
18496
  }), array(MediaFileSchema).readonly()), method(object({
17783
18497
  trackId: string(),
17784
18498
  kinds: array(MediaFileKindEnum).optional()
17785
- }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
18499
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
17786
18500
  deviceId: number(),
17787
18501
  timestamp: number(),
17788
18502
  frameWidth: number(),
@@ -18412,6 +19126,28 @@ var ServerUpdateStateSchema = _enum([
18412
19126
  "pending-restart",
18413
19127
  "awaiting-confirmation"
18414
19128
  ]);
19129
+ var ImageContractSchema = object({
19130
+ state: _enum([
19131
+ "in-sync",
19132
+ "behind-patch",
19133
+ "behind-series",
19134
+ "ahead",
19135
+ "unknown"
19136
+ ]),
19137
+ /** The baked seed closure version — the image/app-bundle fingerprint. */
19138
+ seedVersion: string().nullable(),
19139
+ /** Best-known version the deployment contract delivers today. */
19140
+ contractVersion: string().nullable(),
19141
+ /**
19142
+ * Where `contractVersion` came from: a real registry check (`registry`), or
19143
+ * the node's own running version (`running` — a node can never run code
19144
+ * newer than the newest release, so `seed < running` proves image staleness
19145
+ * even before any registry check has run).
19146
+ */
19147
+ contractSource: _enum(["registry", "running"]).nullable(),
19148
+ /** One operator-grade sentence: this node runs image X; the contract says Y. */
19149
+ message: string()
19150
+ });
18415
19151
  var ServerRollbackInfoSchema = object({
18416
19152
  /** The version that failed (or was manually rolled back). */
18417
19153
  fromVersion: string(),
@@ -18448,7 +19184,12 @@ var ServerPackageStatusSchema = object({
18448
19184
  * versions are being IGNORED. Surfaced as a warning in the UI.
18449
19185
  */
18450
19186
  stateFileCorrupt: boolean(),
18451
- lastCheckedAtMs: number().nullable()
19187
+ lastCheckedAtMs: number().nullable(),
19188
+ /**
19189
+ * Seed-vs-contract verdict (see {@link ImageContractSchema}). Optional for
19190
+ * version skew: an older provider's payload simply omits it.
19191
+ */
19192
+ imageContract: ImageContractSchema.optional()
18452
19193
  });
18453
19194
  var ServerUpdateCheckResultSchema = object({
18454
19195
  packageName: string(),
@@ -18483,101 +19224,6 @@ version: string().optional() }), ServerUpdateActionResultSchema, {
18483
19224
  auth: "admin"
18484
19225
  });
18485
19226
  /**
18486
- * Query filter for settings-store collections.
18487
- */
18488
- var QueryFilterSchema = object({
18489
- where: record(string(), unknown()).optional(),
18490
- whereIn: record(string(), array(unknown())).optional(),
18491
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
18492
- orderBy: object({
18493
- field: string(),
18494
- direction: _enum(["asc", "desc"])
18495
- }).optional(),
18496
- limit: number().optional(),
18497
- offset: number().optional()
18498
- });
18499
- /** A single stored record: `{ id, data }`. */
18500
- var SettingsRecordSchema = object({
18501
- id: string(),
18502
- data: record(string(), unknown())
18503
- });
18504
- /**
18505
- * Column declaration for a structured (SQL-backed) collection.
18506
- *
18507
- * Logical types — the backend translates each to the matching SQLite
18508
- * storage class and handles per-type marshaling:
18509
- * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
18510
- * - `JSON` — TEXT under the hood; serialised on write, parsed on read
18511
- * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
18512
- */
18513
- var CollectionColumnSchema = object({
18514
- name: string(),
18515
- type: _enum([
18516
- "TEXT",
18517
- "INTEGER",
18518
- "REAL",
18519
- "JSON",
18520
- "BOOLEAN"
18521
- ]),
18522
- primaryKey: boolean().optional(),
18523
- notNull: boolean().optional(),
18524
- unique: boolean().optional()
18525
- });
18526
- var CollectionIndexSchema = object({
18527
- name: string(),
18528
- columns: array(string()).readonly(),
18529
- unique: boolean().optional()
18530
- });
18531
- method(object({
18532
- namespace: string().optional(),
18533
- collection: string(),
18534
- key: string()
18535
- }), unknown()), method(object({
18536
- namespace: string().optional(),
18537
- collection: string(),
18538
- key: string(),
18539
- value: unknown()
18540
- }), _void(), { kind: "mutation" }), method(object({
18541
- namespace: string().optional(),
18542
- collection: string(),
18543
- filter: QueryFilterSchema.optional()
18544
- }), array(SettingsRecordSchema).readonly()), method(object({
18545
- namespace: string().optional(),
18546
- collection: string(),
18547
- record: SettingsRecordSchema
18548
- }), _void(), { kind: "mutation" }), method(object({
18549
- namespace: string().optional(),
18550
- collection: string(),
18551
- id: string(),
18552
- data: record(string(), unknown())
18553
- }), _void(), { kind: "mutation" }), method(object({
18554
- namespace: string().optional(),
18555
- collection: string(),
18556
- key: string()
18557
- }), _void(), { kind: "mutation" }), method(object({
18558
- namespace: string().optional(),
18559
- collection: string(),
18560
- filter: QueryFilterSchema.optional()
18561
- }), number()), method(object({
18562
- namespace: string().optional(),
18563
- collection: string(),
18564
- field: string(),
18565
- bucketSize: number().int().positive(),
18566
- origin: number().int(),
18567
- filter: QueryFilterSchema.optional()
18568
- }), array(object({
18569
- bucket: number().int(),
18570
- count: number().int()
18571
- })).readonly()), method(object({
18572
- namespace: string().optional(),
18573
- collection: string()
18574
- }), boolean()), method(object({
18575
- namespace: string().optional(),
18576
- collection: string(),
18577
- columns: array(CollectionColumnSchema).readonly(),
18578
- indexes: array(CollectionIndexSchema).readonly().optional()
18579
- }), _void(), { kind: "mutation" });
18580
- /**
18581
19227
  * `smtp-provider` — pluggable email delivery surface.
18582
19228
  *
18583
19229
  * Collection cap: a deployment may install multiple SMTP relays (e.g.
@@ -22440,6 +23086,54 @@ Object.freeze({
22440
23086
  addonId: null,
22441
23087
  access: "create"
22442
23088
  },
23089
+ "coreBlocks.compile": {
23090
+ capName: "core-blocks",
23091
+ capScope: "system",
23092
+ addonId: null,
23093
+ access: "create"
23094
+ },
23095
+ "coreBlocks.create": {
23096
+ capName: "core-blocks",
23097
+ capScope: "system",
23098
+ addonId: null,
23099
+ access: "create"
23100
+ },
23101
+ "coreBlocks.delete": {
23102
+ capName: "core-blocks",
23103
+ capScope: "system",
23104
+ addonId: null,
23105
+ access: "delete"
23106
+ },
23107
+ "coreBlocks.get": {
23108
+ capName: "core-blocks",
23109
+ capScope: "system",
23110
+ addonId: null,
23111
+ access: "view"
23112
+ },
23113
+ "coreBlocks.getTypeDefs": {
23114
+ capName: "core-blocks",
23115
+ capScope: "system",
23116
+ addonId: null,
23117
+ access: "view"
23118
+ },
23119
+ "coreBlocks.list": {
23120
+ capName: "core-blocks",
23121
+ capScope: "system",
23122
+ addonId: null,
23123
+ access: "view"
23124
+ },
23125
+ "coreBlocks.setEnabled": {
23126
+ capName: "core-blocks",
23127
+ capScope: "system",
23128
+ addonId: null,
23129
+ access: "create"
23130
+ },
23131
+ "coreBlocks.update": {
23132
+ capName: "core-blocks",
23133
+ capScope: "system",
23134
+ addonId: null,
23135
+ access: "create"
23136
+ },
22443
23137
  "cover.close": {
22444
23138
  capName: "cover",
22445
23139
  capScope: "device",
@@ -22476,6 +23170,84 @@ Object.freeze({
22476
23170
  addonId: null,
22477
23171
  access: "view"
22478
23172
  },
23173
+ "dataStoreProvider.count": {
23174
+ capName: "data-store-provider",
23175
+ capScope: "system",
23176
+ addonId: null,
23177
+ access: "view"
23178
+ },
23179
+ "dataStoreProvider.declareCollection": {
23180
+ capName: "data-store-provider",
23181
+ capScope: "system",
23182
+ addonId: null,
23183
+ access: "create"
23184
+ },
23185
+ "dataStoreProvider.delete": {
23186
+ capName: "data-store-provider",
23187
+ capScope: "system",
23188
+ addonId: null,
23189
+ access: "delete"
23190
+ },
23191
+ "dataStoreProvider.deleteWhere": {
23192
+ capName: "data-store-provider",
23193
+ capScope: "system",
23194
+ addonId: null,
23195
+ access: "delete"
23196
+ },
23197
+ "dataStoreProvider.get": {
23198
+ capName: "data-store-provider",
23199
+ capScope: "system",
23200
+ addonId: null,
23201
+ access: "view"
23202
+ },
23203
+ "dataStoreProvider.getEngineInfo": {
23204
+ capName: "data-store-provider",
23205
+ capScope: "system",
23206
+ addonId: null,
23207
+ access: "view"
23208
+ },
23209
+ "dataStoreProvider.histogram": {
23210
+ capName: "data-store-provider",
23211
+ capScope: "system",
23212
+ addonId: null,
23213
+ access: "view"
23214
+ },
23215
+ "dataStoreProvider.insert": {
23216
+ capName: "data-store-provider",
23217
+ capScope: "system",
23218
+ addonId: null,
23219
+ access: "create"
23220
+ },
23221
+ "dataStoreProvider.isEmpty": {
23222
+ capName: "data-store-provider",
23223
+ capScope: "system",
23224
+ addonId: null,
23225
+ access: "view"
23226
+ },
23227
+ "dataStoreProvider.query": {
23228
+ capName: "data-store-provider",
23229
+ capScope: "system",
23230
+ addonId: null,
23231
+ access: "view"
23232
+ },
23233
+ "dataStoreProvider.set": {
23234
+ capName: "data-store-provider",
23235
+ capScope: "system",
23236
+ addonId: null,
23237
+ access: "create"
23238
+ },
23239
+ "dataStoreProvider.update": {
23240
+ capName: "data-store-provider",
23241
+ capScope: "system",
23242
+ addonId: null,
23243
+ access: "create"
23244
+ },
23245
+ "dataStoreProvider.updateWhere": {
23246
+ capName: "data-store-provider",
23247
+ capScope: "system",
23248
+ addonId: null,
23249
+ access: "create"
23250
+ },
22479
23251
  "dayNight.getOptions": {
22480
23252
  capName: "day-night",
22481
23253
  capScope: "device",
@@ -24270,18 +25042,36 @@ Object.freeze({
24270
25042
  addonId: null,
24271
25043
  access: "create"
24272
25044
  },
25045
+ "notificationRules.cancelSnooze": {
25046
+ capName: "notification-rules",
25047
+ capScope: "system",
25048
+ addonId: null,
25049
+ access: "create"
25050
+ },
24273
25051
  "notificationRules.createRule": {
24274
25052
  capName: "notification-rules",
24275
25053
  capScope: "system",
24276
25054
  addonId: null,
24277
25055
  access: "create"
24278
25056
  },
25057
+ "notificationRules.createSnooze": {
25058
+ capName: "notification-rules",
25059
+ capScope: "system",
25060
+ addonId: null,
25061
+ access: "create"
25062
+ },
24279
25063
  "notificationRules.deleteRule": {
24280
25064
  capName: "notification-rules",
24281
25065
  capScope: "system",
24282
25066
  addonId: null,
24283
25067
  access: "delete"
24284
25068
  },
25069
+ "notificationRules.getAlarmConfig": {
25070
+ capName: "notification-rules",
25071
+ capScope: "system",
25072
+ addonId: null,
25073
+ access: "view"
25074
+ },
24285
25075
  "notificationRules.getConditionCatalog": {
24286
25076
  capName: "notification-rules",
24287
25077
  capScope: "system",
@@ -24306,6 +25096,18 @@ Object.freeze({
24306
25096
  addonId: null,
24307
25097
  access: "view"
24308
25098
  },
25099
+ "notificationRules.listSnoozes": {
25100
+ capName: "notification-rules",
25101
+ capScope: "system",
25102
+ addonId: null,
25103
+ access: "view"
25104
+ },
25105
+ "notificationRules.setAlarmConfig": {
25106
+ capName: "notification-rules",
25107
+ capScope: "system",
25108
+ addonId: null,
25109
+ access: "create"
25110
+ },
24309
25111
  "notificationRules.setRuleEnabled": {
24310
25112
  capName: "notification-rules",
24311
25113
  capScope: "system",
@@ -24510,6 +25312,12 @@ Object.freeze({
24510
25312
  addonId: null,
24511
25313
  access: "view"
24512
25314
  },
25315
+ "pipelineAnalytics.listEventKindsBatch": {
25316
+ capName: "pipeline-analytics",
25317
+ capScope: "device",
25318
+ addonId: null,
25319
+ access: "view"
25320
+ },
24513
25321
  "pipelineAnalytics.listOpsLog": {
24514
25322
  capName: "pipeline-analytics",
24515
25323
  capScope: "device",
@@ -24522,6 +25330,12 @@ Object.freeze({
24522
25330
  addonId: null,
24523
25331
  access: "view"
24524
25332
  },
25333
+ "pipelineAnalytics.listTrackMedia": {
25334
+ capName: "pipeline-analytics",
25335
+ capScope: "device",
25336
+ addonId: null,
25337
+ access: "view"
25338
+ },
24525
25339
  "pipelineAnalytics.listTracks": {
24526
25340
  capName: "pipeline-analytics",
24527
25341
  capScope: "device",
@@ -25554,6 +26368,12 @@ Object.freeze({
25554
26368
  addonId: null,
25555
26369
  access: "delete"
25556
26370
  },
26371
+ "settingsStore.deleteWhere": {
26372
+ capName: "settings-store",
26373
+ capScope: "system",
26374
+ addonId: null,
26375
+ access: "delete"
26376
+ },
25557
26377
  "settingsStore.get": {
25558
26378
  capName: "settings-store",
25559
26379
  capScope: "system",
@@ -25596,6 +26416,12 @@ Object.freeze({
25596
26416
  addonId: null,
25597
26417
  access: "create"
25598
26418
  },
26419
+ "settingsStore.updateWhere": {
26420
+ capName: "settings-store",
26421
+ capScope: "system",
26422
+ addonId: null,
26423
+ access: "create"
26424
+ },
25599
26425
  "smtpProvider.getStatus": {
25600
26426
  capName: "smtp-provider",
25601
26427
  capScope: "system",
@@ -37363,10 +38189,12 @@ async function prepareStream(request, sessions, bctx) {
37363
38189
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
37364
38190
  });
37365
38191
  let videoOutSrtp;
38192
+ let videoOutSrtcp;
37366
38193
  let audioOutSrtp;
37367
38194
  let audioOutSrtcp;
37368
38195
  try {
37369
38196
  videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
38197
+ videoOutSrtcp = makeOutSrtcp(request.video.srtp_key, request.video.srtp_salt);
37370
38198
  audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
37371
38199
  audioOutSrtcp = makeOutSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
37372
38200
  } catch (err) {
@@ -37400,6 +38228,12 @@ async function prepareStream(request, sessions, bctx) {
37400
38228
  videoUdp,
37401
38229
  videoLoopUdp,
37402
38230
  videoOutSrtp,
38231
+ videoOutSrtcp,
38232
+ videoOutPacketCount: 0,
38233
+ videoOutOctetCount: 0,
38234
+ videoOutLastRtpTimestamp: 0,
38235
+ videoOutLastRtcpAt: 0,
38236
+ videoRtcpIntervalMs: 5e3,
37403
38237
  videoSendGate: null,
37404
38238
  videoSsrc,
37405
38239
  hapAudioPort: request.audio.port,
@@ -37444,9 +38278,7 @@ async function prepareStream(request, sessions, bctx) {
37444
38278
  audioLoopUdp.on("message", (rtpPacket) => {
37445
38279
  audioPacketsForwarded += 1;
37446
38280
  if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
37447
- if (audioPacketsForwarded === 1) process.stderr.write(`[AUDIO-LISTENER] first packet — about to call forwardEncryptedRtp len=${rtpPacket.length} typeofFn=${typeof forwardEncryptedRtp}\n`);
37448
38281
  forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
37449
- if (audioPacketsForwarded === 1) process.stderr.write("[AUDIO-LISTENER] forwardEncryptedRtp returned for first packet\n");
37450
38282
  });
37451
38283
  audioUdp.on("message", (packet, rinfo) => {
37452
38284
  handleIncomingAudioRtp(session, packet, rinfo.address, bctx).catch((err) => {
@@ -37568,32 +38400,48 @@ function ntpTime() {
37568
38400
  return secs << 32n | frac;
37569
38401
  }
37570
38402
  /**
37571
- * Emit one RTCP Sender Report for the downstream audio leg. Carries
37572
- * the cumulative packet + octet counts plus the (NTP, RTP) timestamp
37573
- * pair that lets iOS align speaker playback against wall-clock time.
37574
- * Encrypted via the per-session SRTCP context (same master key/salt
37575
- * pair as the SRTP audio session) and sent to the same `(hapAddress,
37576
- * hapAudioPort)` the SRTP packets go to.
38403
+ * Emit one RTCP Sender Report for a downstream leg. Carries the cumulative
38404
+ * packet + octet counts plus the (NTP, RTP) timestamp pair the controller
38405
+ * needs to anchor that leg against wall-clock time. Encrypted via the leg's
38406
+ * SRTCP context (same master key/salt pair as its SRTP session) and sent to
38407
+ * the same `(hapAddress, port)` the SRTP packets go to.
38408
+ *
38409
+ * BOTH legs need this, not just audio. HAP negotiates `rtcp_interval`
38410
+ * separately for video and audio, and a Sender Report is the only thing that
38411
+ * pairs an NTP instant with an RTP timestamp — without it the controller
38412
+ * cannot place the stream on a clock. The audio leg alone was covered for a
38413
+ * year (the speaker went mute without it, which made the omission visible);
38414
+ * the video leg has the same requirement and no equally loud symptom.
37577
38415
  */
37578
- function sendAudioRtcpSr(session, log) {
37579
- if (!session.audioOutSrtcp) return;
38416
+ function sendRtcpSr(session, kind, log) {
38417
+ const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
38418
+ if (!srtcp) return;
38419
+ const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38420
+ const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
37580
38421
  try {
37581
38422
  const sr = new import_src.RtcpSrPacket({
37582
- ssrc: session.audioOutSsrc,
38423
+ ssrc: kind === "video" ? session.videoSsrc : session.audioOutSsrc,
37583
38424
  senderInfo: new import_src.RtcpSenderInfo({
37584
38425
  ntpTimestamp: ntpTime(),
37585
- rtpTimestamp: session.audioOutLastRtpTimestamp,
37586
- packetCount: session.audioOutPacketCount,
37587
- octetCount: session.audioOutOctetCount
38426
+ rtpTimestamp: kind === "video" ? session.videoOutLastRtpTimestamp : session.audioOutLastRtpTimestamp,
38427
+ packetCount: kind === "video" ? session.videoOutPacketCount : session.audioOutPacketCount,
38428
+ octetCount: kind === "video" ? session.videoOutOctetCount : session.audioOutOctetCount
37588
38429
  })
37589
38430
  });
37590
- const encrypted = session.audioOutSrtcp.encrypt(sr.serialize());
37591
- session.audioUdp.send(encrypted, session.hapAudioPort, session.hapAddress, (err) => {
37592
- if (err) log.debug("export-hap: audio RTCP send error", { meta: { error: err.message } });
38431
+ const encrypted = srtcp.encrypt(sr.serialize());
38432
+ sink.send(encrypted, port, session.hapAddress, (err) => {
38433
+ if (err) log.debug("export-hap: RTCP send error", { meta: {
38434
+ kind,
38435
+ error: err.message
38436
+ } });
37593
38437
  });
37594
- session.audioOutLastRtcpAt = Date.now();
38438
+ if (kind === "video") session.videoOutLastRtcpAt = Date.now();
38439
+ else session.audioOutLastRtcpAt = Date.now();
37595
38440
  } catch (err) {
37596
- log.debug("export-hap: audio RTCP SR build/encrypt failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
38441
+ log.debug("export-hap: RTCP SR build/encrypt failed", { meta: {
38442
+ kind,
38443
+ error: err instanceof Error ? err.message : String(err)
38444
+ } });
37597
38445
  }
37598
38446
  }
37599
38447
  /**
@@ -37612,21 +38460,18 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
37612
38460
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
37613
38461
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
37614
38462
  const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
37615
- if (kind === "audio" && session.audioOutPacketCount === 0) {
37616
- process.stderr.write(`[FORWARD-AUDIO-DIAG] hasSrtp=${!!srtp} hasGate=${!!gate} audioOutSsrc=${session.audioOutSsrc} scale=${session.audioIntervalScale} hasStartParams=${!!session.lastStartParams}\n`);
37617
- log.info("export-hap: forwardEncryptedRtp audio entry diag", { meta: {
37618
- hasSrtp: !!srtp,
37619
- hasGate: !!gate,
37620
- audioOutSsrc: session.audioOutSsrc,
37621
- audioIntervalScale: session.audioIntervalScale,
37622
- hasLastStartParams: !!session.lastStartParams
37623
- } });
37624
- }
37625
38463
  if (!srtp || !gate) return;
37626
38464
  gate.then(() => {
37627
38465
  try {
37628
38466
  const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
37629
38467
  let firstAudio = false;
38468
+ let firstVideo = false;
38469
+ if (kind === "video") {
38470
+ if (session.videoOutPacketCount === 0) firstVideo = true;
38471
+ session.videoOutPacketCount += 1;
38472
+ session.videoOutOctetCount += parsed.payload.length;
38473
+ session.videoOutLastRtpTimestamp = parsed.header.timestamp;
38474
+ }
37630
38475
  if (kind === "audio") {
37631
38476
  const originalTimestamp = parsed.header.timestamp;
37632
38477
  if (session.firstAudioOutTimestamp === null) {
@@ -37656,9 +38501,10 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
37656
38501
  error: err.message
37657
38502
  } });
37658
38503
  });
37659
- if (kind === "audio") {
37660
- if (firstAudio || Date.now() > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendAudioRtcpSr(session, log);
37661
- }
38504
+ const now = Date.now();
38505
+ if (kind === "video") {
38506
+ if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
38507
+ } else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
37662
38508
  } catch (err) {
37663
38509
  log.debug("export-hap: SRTP encrypt failed", { meta: {
37664
38510
  kind,
@@ -37696,6 +38542,11 @@ async function handleStreamRequest(request, sessions, bctx) {
37696
38542
  session.audioOutLastRtpTimestamp = 0;
37697
38543
  session.audioOutLastRtcpAt = 0;
37698
38544
  session.audioRtcpIntervalMs = (request.audio.rtcp_interval > 0 ? request.audio.rtcp_interval : 5) * 1e3;
38545
+ session.videoRtcpIntervalMs = (request.video.rtcp_interval > 0 ? request.video.rtcp_interval : 5) * 1e3;
38546
+ session.videoOutPacketCount = 0;
38547
+ session.videoOutOctetCount = 0;
38548
+ session.videoOutLastRtpTimestamp = 0;
38549
+ session.videoOutLastRtcpAt = 0;
37699
38550
  session.lastStartParams = {
37700
38551
  pt: request.video.pt,
37701
38552
  mtu: request.video.mtu,
@@ -37727,6 +38578,10 @@ async function handleStreamRequest(request, sessions, bctx) {
37727
38578
  if (session.ffmpeg) killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
37728
38579
  session.firstAudioOutTimestamp = null;
37729
38580
  session.audioOutPacketCount = 0;
38581
+ session.videoOutPacketCount = 0;
38582
+ session.videoOutOctetCount = 0;
38583
+ session.videoOutLastRtpTimestamp = 0;
38584
+ session.videoOutLastRtcpAt = 0;
37730
38585
  const startParams = {
37731
38586
  width: request.video.width,
37732
38587
  height: request.video.height,