@camstack/addon-provider-homeassistant 1.2.5 → 1.2.7

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.
Files changed (3) hide show
  1. package/dist/addon.js +1752 -1203
  2. package/dist/addon.mjs +1752 -1203
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -7317,35 +7317,22 @@ function buildCapturingReply(envelope) {
7317
7317
  */
7318
7318
  var RecordingWeekdaySchema = number().int().min(0).max(6);
7319
7319
  var HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
7320
- var RecordingScheduleSchema = discriminatedUnion("kind", [object({ kind: literal("always") }), object({
7321
- kind: literal("timeOfDay"),
7322
- start: string().regex(HHMM),
7323
- end: string().regex(HHMM),
7324
- /** Restrict to these weekdays; omit = every day. */
7325
- days: array(RecordingWeekdaySchema).optional()
7326
- })]);
7327
- var RecordingModeSchema = _enum([
7328
- "continuous",
7329
- "onMotion",
7330
- "onAudioThreshold"
7331
- ]);
7332
7320
  /**
7333
- * First-class, authoritative per-camera storage mode — the explicit choice the
7334
- * UI reads directly (never inferred from `rules`):
7335
- * - `off` — not recording.
7336
- * - `events` — record only around triggers (motion / audio threshold),
7337
- * with pre/post-buffer.
7338
- * - `continuous` — record 24/7 within the schedule.
7321
+ * DERIVED per-camera storage summary — the single field cheap consumers read
7322
+ * (the viewer's status dot, the camera list) instead of walking `bands`:
7323
+ * - `off` — no band covers the camera (or it is disabled).
7324
+ * - `events` — every band records around triggers only.
7325
+ * - `continuous` — at least one band records continuously.
7339
7326
  *
7340
- * `mode` compiles one-way to the internal `rules[]` consumed by the policy
7341
- * engine (see `compileRules`); `rules[]` is never authored directly anymore.
7327
+ * NEVER authored: the recorder stamps it from the authoritative `bands` on
7328
+ * every save (`activeModeForConfig`). Writing it has no effect.
7342
7329
  */
7343
7330
  var RecordingStorageModeSchema = _enum([
7344
7331
  "off",
7345
7332
  "events",
7346
7333
  "continuous"
7347
7334
  ]);
7348
- /** Which detectors trigger an `events`-mode recording. */
7335
+ /** Which detectors trigger an `events`-mode band. */
7349
7336
  var RecordingTriggersSchema = object({
7350
7337
  motion: boolean().optional(),
7351
7338
  audioThresholdDbfs: number().optional()
@@ -7381,18 +7368,6 @@ var RecordingBandSchema = object({
7381
7368
  preBufferSec: number().min(0).optional(),
7382
7369
  postBufferSec: number().min(0).optional()
7383
7370
  });
7384
- var RecordingRuleSchema = object({
7385
- schedule: RecordingScheduleSchema,
7386
- mode: RecordingModeSchema,
7387
- /** Seconds of footage to retain BEFORE a trigger (applied at keep/discard). */
7388
- preBufferSec: number().min(0).default(0),
7389
- /** Keep recording until this many seconds after the last trigger. */
7390
- postBufferSec: number().min(0).default(0),
7391
- /** Each new trigger restarts the post-buffer window. */
7392
- resetTimeoutOnNewEvent: boolean().default(true),
7393
- /** onAudioThreshold only — dBFS level that counts as a trigger. */
7394
- thresholdDbfs: number().optional()
7395
- });
7396
7371
  /**
7397
7372
  * Per-device retention overrides. Every field is optional; an unset or `0`
7398
7373
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7426,40 +7401,28 @@ var ScrubThumbnailPresetSchema = _enum([
7426
7401
  /**
7427
7402
  * The full per-camera recording intent — the wire shape of a RecordingTarget.
7428
7403
  *
7429
- * `mode` is the authoritative storage choice; `schedule`/`triggers`/`pre`/`post`
7430
- * are its mode-specific parameters. `rules` is a DEPRECATED authoring input kept
7431
- * only for transition + migration (`migrateRulesToMode`); the policy engine
7432
- * consumes the compiled output of `compileRules(config)`, never `rules` directly.
7404
+ * `bands` is the ONLY authored recording intent: what to record, when, and on
7405
+ * which trigger. `mode` is a derived summary the recorder stamps on save; every
7406
+ * other field is a storage knob (profiles, segment length, retention, scrub).
7407
+ *
7408
+ * STRICT on purpose: the legacy authoring surface (`schedule`/`schedules`/
7409
+ * `triggers`/`preBufferSec`/`postBufferSec`/`rules`) was retired 2026-07-30.
7410
+ * A stale caller must fail loudly — silently stripping its legacy intent would
7411
+ * persist a band-less config, i.e. silently stop recording the camera.
7433
7412
  */
7434
7413
  var RecordingConfigSchema = object({
7435
7414
  enabled: boolean(),
7436
- /** Authoritative storage mode. Absent on legacy targets derived once via
7437
- * `migrateRulesToMode`, then persisted. */
7415
+ /** DERIVED summary of `bands`, stamped by the recorder on every save.
7416
+ * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7438
7417
  mode: RecordingStorageModeSchema.optional(),
7439
7418
  profiles: array(CamProfileSchema).optional(),
7440
7419
  segmentSeconds: number().int().positive().optional(),
7441
- /** Shared recording time-bands for `events` & `continuous` — record only when
7442
- * the wall-clock falls inside one of these windows. Omit or empty = always.
7443
- * `continuous` compiles to one rule per band; `events` to band × trigger. */
7444
- schedules: array(RecordingScheduleSchema).optional(),
7445
- /** Legacy single-band predecessor of `schedules`. Read-compat only — it is
7446
- * normalized into `schedules` on read and never written going forward. (Not
7447
- * tagged `@deprecated`: the normalization paths must read it cast-free.) */
7448
- schedule: RecordingScheduleSchema.optional(),
7449
- /** `events`-mode only — which detectors trigger a recording. */
7450
- triggers: RecordingTriggersSchema.optional(),
7451
- /** `events`-mode only — seconds retained before / after a trigger. */
7452
- preBufferSec: number().min(0).optional(),
7453
- postBufferSec: number().min(0).optional(),
7454
- /** DEPRECATED authoring input; retained for migration/transition. */
7455
- rules: array(RecordingRuleSchema).optional(),
7456
7420
  /**
7457
- * AUTHORITATIVE mode-per-band recording model (recorder). When present it
7458
- * is the single source of truth; the legacy `mode`/`schedules`/`schedule`/
7459
- * `triggers`/`rules` fields above are kept for READ-COMPAT only and are
7460
- * derived into bands once via `migrateConfigToBands`.
7421
+ * AUTHORITATIVE mode-per-band recording model the single source of truth
7422
+ * the recorder's band engine consumes. An empty array = record nothing;
7423
+ * "off" is the absence of a covering band, never a band value.
7461
7424
  */
7462
- bands: array(RecordingBandSchema).optional(),
7425
+ bands: array(RecordingBandSchema).default([]),
7463
7426
  retention: RecordingRetentionSchema.optional(),
7464
7427
  /**
7465
7428
  * Per-camera scrub-thumbnail fidelity preset (resolution + JPEG quality for
@@ -7467,8 +7430,15 @@ var RecordingConfigSchema = object({
7467
7430
  * windows only — existing sheets are immutable, and each window's index
7468
7431
  * carries its own tile dims so mixed-preset history renders correctly.
7469
7432
  */
7470
- scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7471
- });
7433
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional(),
7434
+ /**
7435
+ * OPT-IN thumbnail-strip generation for this camera: every keyframe of the
7436
+ * low recording saved as a JPEG (the fast-drag scrub depth), a derived
7437
+ * cache that eviction reclaims with the footage. Absent/false = no strips
7438
+ * are written and scrub reads exact keyframes at every velocity.
7439
+ */
7440
+ stripsEnabled: boolean().optional()
7441
+ }).strict();
7472
7442
  /**
7473
7443
  * Ops-log — the durable, append-only operations audit shared by the
7474
7444
  * recordings and events management surfaces.
@@ -7487,7 +7457,8 @@ var OpsLogOpSchema = _enum([
7487
7457
  "prune",
7488
7458
  "manual-delete",
7489
7459
  "rescan",
7490
- "retention-run"
7460
+ "retention-run",
7461
+ "relocate"
7491
7462
  ]);
7492
7463
  /** Why the operation ran. */
7493
7464
  var OpsLogReasonSchema = _enum([
@@ -7526,6 +7497,55 @@ var OpsLogQueryInputSchema = object({
7526
7497
  limit: number().int().min(1).max(1e3).optional()
7527
7498
  });
7528
7499
  /**
7500
+ * Entity-relocation job state (storage entity-routing spec, Phase 4).
7501
+ *
7502
+ * One shape shared by the recorder's `relocateFootage` (segments + strips) and
7503
+ * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7504
+ * page renders both movers with one component. Jobs are in-RAM (a restart
7505
+ * forgets them — re-running is safe by construction: copy-if-absent, delete
7506
+ * after verify) and each completed/failed run also lands one durable ops-log
7507
+ * row on the owning addon's surface.
7508
+ */
7509
+ var RelocateJobStateSchema = _enum([
7510
+ "running",
7511
+ "done",
7512
+ "failed",
7513
+ "cancelled"
7514
+ ]);
7515
+ var RelocateJobSchema = object({
7516
+ jobId: string(),
7517
+ state: RelocateJobStateSchema,
7518
+ /** Source location — for media relocation this is informational ('*': rows
7519
+ * move from wherever they are to the target). */
7520
+ fromLocationId: string(),
7521
+ toLocationId: string(),
7522
+ /** Scoped device, or null = every device. */
7523
+ deviceId: number().nullable(),
7524
+ /** What the job moves (owner-addon specific: segments/strips or media). */
7525
+ entities: array(string()),
7526
+ filesMoved: number().int(),
7527
+ bytesMoved: number().int(),
7528
+ /** Total files discovered up front; null while (or when) unknown. */
7529
+ filesTotal: number().int().nullable(),
7530
+ startedAt: number(),
7531
+ finishedAt: number().nullable(),
7532
+ error: string().nullable()
7533
+ });
7534
+ var RelocateFootageInputSchema = object({
7535
+ deviceId: number().optional(),
7536
+ fromLocationId: string(),
7537
+ toLocationId: string(),
7538
+ entities: array(_enum(["segments", "strips"])).optional(),
7539
+ /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7540
+ * never allowed to starve live writers. */
7541
+ throttleMbps: number().min(1).max(1e3).optional()
7542
+ });
7543
+ var RelocateMediaInputSchema = object({
7544
+ deviceId: number().optional(),
7545
+ toLocationId: string(),
7546
+ throttleMbps: number().min(1).max(1e3).optional()
7547
+ });
7548
+ /**
7529
7549
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
7530
7550
  * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
7531
7551
  * so the persisted record schema and the consumer-facing cap can both consume it
@@ -7575,6 +7595,13 @@ var StorageLocationSchema = object({
7575
7595
  nodeId: string().optional(),
7576
7596
  isDefault: boolean().default(false),
7577
7597
  isSystem: boolean().default(false),
7598
+ /** COMPUTED at read time by the orchestrator (statfs of the backing volume
7599
+ * for node-local locations it can reach) — never persisted, absent when the
7600
+ * volume is remote/unreachable. The single capacity truth every UI reads. */
7601
+ capacity: object({
7602
+ totalBytes: number(),
7603
+ availableBytes: number()
7604
+ }).nullable().optional(),
7578
7605
  createdAt: number(),
7579
7606
  updatedAt: number()
7580
7607
  });
@@ -7636,16 +7663,23 @@ var StorageLocationDeclarationSchema = object({
7636
7663
  * Which node root the seeded `<id>:default` instance is placed under on a
7637
7664
  * FRESH install:
7638
7665
  * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
7639
- * the appData volume. Right for small/durable data (backups, logs, models).
7666
+ * the appData volume. Right for small/durable data (logs, models).
7640
7667
  * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
7641
7668
  * env is set, else falls back to the data root. Right for bulky, hot media
7642
7669
  * (recordings, event media) that should stay off the appData disk.
7670
+ * - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
7671
+ * `/backups` in the image) so archives live on their own mount rather than
7672
+ * filling the appData disk. Falls back to the data root when unset.
7643
7673
  *
7644
7674
  * Only affects the seeded default's `basePath`; operators can repoint any
7645
7675
  * location afterwards, and a `defaultsTo` slot inherits its parent's root
7646
7676
  * regardless of this field. Absent (the common case) is treated as `'data'`.
7647
7677
  */
7648
- defaultRoot: _enum(["data", "media"]).optional()
7678
+ defaultRoot: _enum([
7679
+ "data",
7680
+ "media",
7681
+ "backup"
7682
+ ]).optional()
7649
7683
  });
7650
7684
  var DecoderStatsSchema = object({
7651
7685
  inputFps: number(),
@@ -8335,7 +8369,8 @@ var NcTaxonomyEntrySchema = object({
8335
8369
  /** Macro/category parent for grouping ('car' → 'vehicle'); null for a top. */
8336
8370
  parentKind: string().nullable()
8337
8371
  });
8338
- object({
8372
+ /** The complete NC picker taxonomy — three grouped buckets. */
8373
+ var NcTaxonomySchema = object({
8339
8374
  videoClasses: array(NcTaxonomyEntrySchema),
8340
8375
  audioKinds: array(NcTaxonomyEntrySchema),
8341
8376
  labels: array(NcTaxonomyEntrySchema)
@@ -9301,9 +9336,20 @@ var DEGRADE_ORDER = {
9301
9336
  "markdown"
9302
9337
  ]
9303
9338
  };
9304
- /** Pick the best target format the kind supports for a given source format. */
9339
+ /**
9340
+ * Pick the best target format the kind supports for a given source format.
9341
+ *
9342
+ * TOLERANT of a source outside the union. `NotificationSchema.format` carries
9343
+ * `.default('text')`, but that default only runs where the schema is PARSED —
9344
+ * the tRPC router. A cap call arriving from another addon (`ctx.api`) skips
9345
+ * that parse, so `format` reaches here `undefined`, and the bare
9346
+ * `DEGRADE_ORDER[source]` threw `is not iterable`. That broke the one promise
9347
+ * the degrade path makes ("never throws — degradation is reported, never
9348
+ * surfaced as an error") and dead-lettered every Notification-Center delivery
9349
+ * for a day (2026-07-30). An unknown source degrades like `text`.
9350
+ */
9305
9351
  function resolveFormat(source, supported) {
9306
- for (const candidate of DEGRADE_ORDER[source]) if (supported.includes(candidate)) return candidate;
9352
+ for (const candidate of DEGRADE_ORDER[source] ?? DEGRADE_ORDER.text) if (supported.includes(candidate)) return candidate;
9307
9353
  return supported[0] ?? "text";
9308
9354
  }
9309
9355
  /** Transcode a body between formats. */
@@ -9348,7 +9394,7 @@ function resolveLevel(levels, n) {
9348
9394
  })[0]);
9349
9395
  }
9350
9396
  function splitBody(body, maxLen) {
9351
- if (maxLen <= 0 || body.length <= maxLen) return [body];
9397
+ if (!Number.isFinite(maxLen) || maxLen <= 0 || body.length <= maxLen) return [body];
9352
9398
  const parts = [];
9353
9399
  for (let i = 0; i < body.length; i += maxLen) parts.push(body.slice(i, i + maxLen));
9354
9400
  return parts;
@@ -9431,147 +9477,849 @@ function prepareNotification(caps, n) {
9431
9477
  renderedAs
9432
9478
  };
9433
9479
  }
9480
+ new Set(["devices", "classes"]);
9434
9481
  /**
9435
- * Generic device-level status snapshot. Auto-registered by `BaseDevice`
9436
- * for every device, regardless of provider the kernel needs a uniform
9437
- * cap-keyed slice for the basic device flags every consumer expects to
9438
- * read across processes (the `online` flag in particular). Driver-specific
9439
- * caps (`battery`, `doorbell`, …) carry their domain-specific state on
9440
- * their own slices.
9482
+ * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
9483
+ * motion-zones, and the detection zones/lines editor all speak this one
9484
+ * language so a single drawing-plane editor and the providers stay
9485
+ * decoupled from each cap's storage.
9441
9486
  *
9442
- * Pattern is identical to `battery`: schema-bearing `runtimeState`,
9443
- * empty `methods`, single change event. Reads land at
9444
- * `runtimeState.getCapState('device-status')`; writes at
9445
- * `runtimeState.setCapState('device-status', …)`. Cross-process
9446
- * consumers reach the same data via the `device-state` cap router
9447
- * (`getCapSlice({deviceId, capName: 'device-status'})`).
9487
+ * All coordinates are normalized 0..1 of the camera frame (top-left
9488
+ * origin). Each cap composes the SUBSET of shape kinds it supports and
9489
+ * advertises it via `supportedShapes` in its `getOptions`.
9448
9490
  */
9449
- var DeviceStatusSchema = object({
9450
- /**
9451
- * Device-level liveness. Drivers flip via `markOnline(boolean)` on
9452
- * `BaseDevice`. Provider semantics vary — RTSP aggregates broker
9453
- * stream-health, Reolink reads firmware push events, ONVIF tracks
9454
- * ping responses. This cap intentionally does NOT prescribe which
9455
- * signal drives the flag.
9456
- */
9457
- online: boolean(),
9458
- /** Ms epoch of the last `online` transition. Lets consumers tell
9459
- * apart "just came online" from "still online". */
9460
- lastChangedAt: number()
9491
+ /** A normalized 0..1 point (top-left origin). */
9492
+ var MaskPointSchema = object({
9493
+ x: number(),
9494
+ y: number()
9495
+ });
9496
+ /** Axis-aligned rectangle (normalized 0..1). */
9497
+ var MaskRectShapeSchema = object({
9498
+ kind: literal("rect"),
9499
+ x: number(),
9500
+ y: number(),
9501
+ width: number(),
9502
+ height: number()
9503
+ });
9504
+ /** Free polygon — an ordered list of normalized vertices (≥3). */
9505
+ var MaskPolygonShapeSchema = object({
9506
+ kind: literal("polygon"),
9507
+ points: array(MaskPointSchema)
9508
+ });
9509
+ /** Boolean cell grid — row-major, length === gridWidth*gridHeight. */
9510
+ var MaskGridShapeSchema = object({
9511
+ kind: literal("grid"),
9512
+ gridWidth: number(),
9513
+ gridHeight: number(),
9514
+ cells: array(boolean())
9515
+ });
9516
+ discriminatedUnion("kind", [
9517
+ MaskRectShapeSchema,
9518
+ MaskPolygonShapeSchema,
9519
+ MaskGridShapeSchema,
9520
+ object({
9521
+ kind: literal("line"),
9522
+ points: array(MaskPointSchema)
9523
+ })
9524
+ ]);
9525
+ /** Every shape-kind discriminant, for `supportedShapes` advertisement. */
9526
+ var MaskShapeKindSchema = _enum([
9527
+ "rect",
9528
+ "polygon",
9529
+ "grid",
9530
+ "line"
9531
+ ]);
9532
+ /** Polygon vertex bounds when a cap supports 'polygon' (e.g. Hikvision {min:4,max:4}). */
9533
+ var MaskPolygonVerticesSchema = object({
9534
+ min: number(),
9535
+ max: number()
9536
+ });
9537
+ /** Grid dimensions when a cap supports 'grid'. */
9538
+ var MaskGridDimsSchema = object({
9539
+ width: number(),
9540
+ height: number()
9461
9541
  });
9462
- var deviceStatusCapability = {
9463
- name: "device-status",
9464
- scope: "device",
9465
- deviceNative: true,
9466
- mode: "singleton",
9467
- methods: {},
9468
- events: {
9469
- /** Emitted when `online` transitions. Mirrors the semantics of
9470
- * `battery.onStatusChanged`. */
9471
- onStatusChanged: { data: object({
9472
- deviceId: number(),
9473
- status: DeviceStatusSchema
9474
- }) } },
9475
- status: {
9476
- schema: DeviceStatusSchema,
9477
- kind: "push"
9478
- },
9479
- runtimeState: DeviceStatusSchema
9480
- };
9481
9542
  /**
9482
- * Per-device feature/identity probe slice. Holds the runtime-resolved
9483
- * truth about what a device CAN do — which the kernel uses to:
9484
- * 1. Reconcile accessory children (hub-children spawn siren/floodlight/PIR
9485
- * based on what the firmware actually advertises).
9486
- * 2. Compute the public `features: DeviceFeature[]` array surfaced via
9487
- * `device-manager.listAll`.
9488
- * 3. Decide which optional caps (PTZ, intercom, doorbell, battery, …)
9489
- * to register on the device's capability surface.
9543
+ * notification-rules the Notification Center rule surface (P1 core).
9490
9544
  *
9491
- * Auto-registered by `BaseDevice` for every device. Drivers populate the
9492
- * slice from `onProbe()` (kernel calls it once after register, before
9493
- * accessory reconciliation). Consumers read via:
9494
- * `runtimeState.getCapState<FeatureProbeStatus>('feature-probe')`
9545
+ * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
9546
+ * (operator decisions D-1/D-2/D-3 are binding):
9495
9547
  *
9496
- * `flags` is an open record so each driver carries its own keys without
9497
- * a centralized schema bottleneck Reolink writes `hasPtz/hasIntercom`,
9498
- * Hikvision writes `hasSupplementalLight/hasAlarmIo`, etc.
9548
+ * - D-2: rule EVALUATION lives in `addon-post-analysis` (the
9549
+ * `notification-center` module), hooked on the durable persistence
9550
+ * moments (object-event insert, TrackCloser.closeExpired) with a
9551
+ * persisted outbox + retry — never the lossy telemetry bus (D8).
9552
+ * - D-3: urgency belongs to the RULE. `delivery: 'immediate'` fires on the
9553
+ * FIRST persisted detection matching the conditions (per-track dedup,
9554
+ * `maxPerTrack` fixed at 1 — see {@link NC_MAX_PER_TRACK_IMMEDIATE});
9555
+ * `delivery: 'track-end'` evaluates the finalized track record at close.
9556
+ * - DISPATCH stays behind `notification-output` (rules reference targets
9557
+ * by id; per-backend params are a passthrough blob capped by the
9558
+ * target kind's own caps/degrade engine).
9499
9559
  *
9500
- * Replaces the older driver-local `deviceCache.has*` blob: the per-device
9501
- * config is for operator-edited overrides + UI snapshots; runtime probe
9502
- * results belong in runtime-state where the kernel handles persistence,
9503
- * cross-process mirroring, and reactive updates.
9560
+ * P1 scope: admin-authored rules only (`createdBy` stamped from the
9561
+ * server-injected caller identity the first `caller: 'required'`
9562
+ * adopter). The P1 condition subset is: devices, classes(+exclude),
9563
+ * minConfidence, admin zones (any/all + exclude), weekly schedule
9564
+ * windows, and the optional label/identity/plate matchers. User rules,
9565
+ * private zones, per-recipient fan-out and the wider condition table are
9566
+ * P2+ (see spec §7).
9567
+ *
9568
+ * All schemas here are the single source of truth — `NcRule` etc. are
9569
+ * `z.infer` exports; no duplicate interfaces (the advanced-notifier
9570
+ * schema/interface drift is explicitly not repeated).
9504
9571
  */
9505
- var FeatureProbeStatusSchema = object({
9506
- /**
9507
- * Driver-specific flag bag. Each driver picks its own key names — the
9508
- * cap deliberately does NOT enforce a closed enum here. Reolink keys:
9509
- * `hasPtz`, `hasIntercom`, `hasDoorbell`, `hasFloodlight`, `hasSiren`,
9510
- * `hasPirSensor`, `hasAutotrack`, `hasBattery`. Hikvision keys:
9511
- * `hasSupplementalLight`, `lightHasWhiteLight`, `hasAlarmIo`, `hasPtz`.
9512
- */
9513
- flags: record(string(), unknown()),
9514
- /**
9515
- * Coarse driver-classification — lets cross-process consumers tell apart
9516
- * cameras / battery-cams / NVRs without re-running the probe. `null`
9517
- * before the first probe completes.
9518
- */
9519
- deviceType: string().nullable(),
9520
- /** Camera/firmware model string. `null` when the firmware doesn't expose it. */
9521
- model: string().nullable(),
9522
- /** Channel count for NVR/Hub devices; `1` for standalone cameras; `null` pre-probe. */
9523
- channelCount: number().nullable(),
9524
- /**
9525
- * Ms epoch of the last SUCCESSFUL probe. `0` before the first probe
9526
- * completes — drivers' `getAccessoryChildren()` should treat zero as
9527
- * "probe not done yet, return empty" so accessories aren't spawned
9528
- * before the firmware is queried.
9529
- */
9530
- lastProbedAt: number(),
9531
- /**
9532
- * Framework convention: every runtime-state slice carries this for the
9533
- * createRuntimeStateBridge stale-check helper. We keep it in sync with
9534
- * `lastProbedAt` on every write.
9535
- */
9536
- lastFetchedAt: number()
9537
- });
9538
- var featureProbeCapability = {
9539
- name: "feature-probe",
9540
- scope: "device",
9541
- deviceNative: true,
9542
- mode: "singleton",
9543
- methods: {},
9544
- events: {
9545
- /** Fires whenever a fresh probe completes (kernel-driven `reprobe()`
9546
- * or driver-initiated re-detect after a state change). */
9547
- onProbeChanged: { data: object({
9548
- deviceId: number(),
9549
- status: FeatureProbeStatusSchema
9550
- }) } },
9551
- status: {
9552
- schema: FeatureProbeStatusSchema,
9553
- kind: "push"
9554
- },
9555
- runtimeState: FeatureProbeStatusSchema
9556
- };
9557
9572
  /**
9558
- * Multi-metric air-quality slice. Covers CO₂, total VOCs, particulate
9559
- * matter at PM2.5 / PM10, and a derived AQI index — all optional so
9560
- * a single-metric source populates only what it observes. Mirrors
9561
- * the HA `sensor` device_class set (`co2`, `volatile_organic_compounds`,
9562
- * `pm25`, `pm10`, `aqi`) collapsed into one cap because a typical
9563
- * air-quality node reports several of these together; modelling them
9564
- * as siblings keeps a single timestamp + one slice subscription.
9573
+ * D-3: the trigger/urgency of a rule which persistence moment evaluates it.
9574
+ * The value maps 1:1 onto the evaluated record kind:
9575
+ * - `immediate` ↔ object-event persist (lowest-latency detection burst)
9576
+ * - `track-end` TrackCloser.closeExpired (finalized track record)
9577
+ * - `device-event` SensorEventStore insert (doorbell press / sensor state
9578
+ * change of a LINKED device, one row per linked camera)
9579
+ * - `package-event` PackageDropDetector object-event insert (a `package`
9580
+ * delivery / pick-up)
9581
+ *
9582
+ * `immediate`/`track-end` carry the D-3 urgency semantics; `device-event`/
9583
+ * `package-event` are pure trigger kinds (no urgency dimension). Extending
9584
+ * this one field keeps the schema additive — a rule still declares exactly
9585
+ * one trigger.
9565
9586
  */
9566
- var AirQualitySensorStatusSchema = object({
9567
- /** Carbon dioxide concentration in ppm. */
9568
- co2Ppm: number().min(0).optional(),
9569
- /** Total volatile organic compounds in ppb. */
9570
- vocPpb: number().min(0).optional(),
9571
- /** Particulate matter ≤ 2.5 μm in µg/m³. */
9572
- pm25: number().min(0).optional(),
9573
- /** Particulate matter ≤ 10 μm in µg/m³. */
9574
- pm10: number().min(0).optional(),
9587
+ var NcDeliverySchema = _enum([
9588
+ "immediate",
9589
+ "track-end",
9590
+ "device-event",
9591
+ "package-event"
9592
+ ]);
9593
+ /** Weekly schedule — OR of windows; absence on the rule = always active. */
9594
+ var NcScheduleSchema = object({
9595
+ windows: array(object({
9596
+ /** Days of week the window STARTS on (0 = Sunday … 6 = Saturday). */
9597
+ days: array(number().int().min(0).max(6)).min(1),
9598
+ startMinute: number().int().min(0).max(1439),
9599
+ endMinute: number().int().min(0).max(1439)
9600
+ })).min(1),
9601
+ /** IANA timezone; default = hub host timezone. */
9602
+ timezone: string().optional(),
9603
+ /** Active OUTSIDE the windows (e.g. "only outside business hours"). */
9604
+ invert: boolean().optional()
9605
+ });
9606
+ /** Fuzzy plate matcher — OCR noise makes exact match useless (spec row 12/13). */
9607
+ var NcPlateMatcherSchema = object({
9608
+ values: array(string().min(1)).min(1),
9609
+ /** Max Levenshtein distance after normalization (uppercase alphanumeric). */
9610
+ maxDistance: number().int().min(0).max(3).default(1)
9611
+ });
9612
+ /**
9613
+ * Occupancy condition (DEVICE-EVENT trigger). Fires on a ZoneAnalytics
9614
+ * occupancy edge for a device — optionally narrowed to a single admin
9615
+ * `zoneId` and/or object `className`. `op` selects the edge/threshold:
9616
+ * - `became-occupied` (default) — count crossed 0 → ≥ `count`
9617
+ * - `became-free` — count crossed ≥ `count` → below it
9618
+ * - `>=` / `<=` — count is at/over or at/under `count`
9619
+ * `sustainSeconds` requires the condition hold continuously that long
9620
+ * before firing (debounces flicker; 0 = fire on the first matching edge).
9621
+ * Fail-closed: no ZoneAnalytics snapshot / missing zone / null snapshot ⇒
9622
+ * the condition never matches. Confirmed edge-state survives addon restarts
9623
+ * (declared SQLite collection, reseeded on boot).
9624
+ */
9625
+ var NcOccupancyConditionSchema = object({
9626
+ /** Admin zone id to scope the count to; absent = whole-frame occupancy. */
9627
+ zoneId: string().optional(),
9628
+ /** Object class to count; absent = any class. */
9629
+ className: string().optional(),
9630
+ op: _enum([
9631
+ "became-occupied",
9632
+ "became-free",
9633
+ ">=",
9634
+ "<="
9635
+ ]).default("became-occupied"),
9636
+ count: number().int().min(0).default(1),
9637
+ sustainSeconds: number().int().min(0).max(3600).default(15)
9638
+ });
9639
+ /** Admin-zone membership condition (zone IDs as stamped by the ZoneEngine). */
9640
+ var NcZoneConditionSchema = object({
9641
+ ids: array(string().min(1)).min(1),
9642
+ /** Quantifier over `ids` — at least one / every one visited. */
9643
+ match: _enum(["any", "all"]).default("any")
9644
+ });
9645
+ /**
9646
+ * The P1 condition set — a flat AND of groups; absent group = pass;
9647
+ * membership lists are OR within the list (spec §2.3).
9648
+ */
9649
+ var NcConditionsSchema = object({
9650
+ /** Device scope — absent = all devices. */
9651
+ devices: array(number()).optional(),
9652
+ /** Detector class names (any overlap with the record's class set). */
9653
+ classes: array(string().min(1)).optional(),
9654
+ /** Veto classes — any overlap fails the rule. */
9655
+ classesExclude: array(string().min(1)).optional(),
9656
+ /** Minimum detection confidence 0–1 (fails when the record has none). */
9657
+ minConfidence: number().min(0).max(1).optional(),
9658
+ /** Admin zone membership over event `zones` / track `zonesVisited`. */
9659
+ zones: NcZoneConditionSchema.optional(),
9660
+ /** Veto zones — any hit fails the rule. */
9661
+ zonesExclude: array(string().min(1)).optional(),
9662
+ /**
9663
+ * Exact (case-insensitive) match on the record's collapsed `label`
9664
+ * (identity name / plate text / subclass).
9665
+ */
9666
+ labelEquals: array(string().min(1)).optional(),
9667
+ /**
9668
+ * Identity matcher. P1 boundary: matched against the record's collapsed
9669
+ * `label` (the identity display name propagated by the face pipeline) —
9670
+ * identity-ID matching rides in P2 when identity ids reach the record.
9671
+ */
9672
+ identities: array(string().min(1)).optional(),
9673
+ /** Fuzzy plate matcher against the record's `label` (plate text). */
9674
+ plates: NcPlateMatcherSchema.optional(),
9675
+ /**
9676
+ * Identity EXCLUDE — mirror of {@link identities} with `notIn` semantics.
9677
+ * Same P1 boundary: matched against the record's collapsed `label` (the
9678
+ * identity display name). A record with NO label passes (nothing to
9679
+ * exclude), unlike the include variant which fails on an absent label.
9680
+ */
9681
+ identitiesExclude: array(string().min(1)).optional(),
9682
+ /**
9683
+ * Minimum server-computed key-event importance in [0,1] (`Track.importance`).
9684
+ * TRACK-END only: importance is scored at track close, so it does not exist
9685
+ * at immediate / object-event evaluation time (see catalog `appliesTo`). At
9686
+ * close the value is threaded via the close-time info (the `Track` clone is
9687
+ * captured before the DB row is updated, so it would otherwise read stale).
9688
+ * Fails when the record carries no importance (never guess quality — the
9689
+ * `minConfidence` precedent). MVP cut: a single scalar threshold.
9690
+ */
9691
+ minImportance: number().min(0).max(1).optional(),
9692
+ /**
9693
+ * Minimum track dwell in SECONDS — `(lastSeen − firstSeen) / 1000`.
9694
+ * TRACK-END only: an `immediate` / object-event subject has no closed
9695
+ * lifespan, so a dwell condition never matches immediate delivery
9696
+ * (documented choice — the object-event record carries no `firstSeen`,
9697
+ * so dwell cannot be computed from what the subject actually carries).
9698
+ */
9699
+ minDwellSeconds: number().min(0).optional(),
9700
+ /**
9701
+ * Detection provenance filter. `any` (default / absent) matches every
9702
+ * source; otherwise the subject's source must equal it. Legacy records
9703
+ * with no stamped source are treated as `pipeline`. The union spans both
9704
+ * record kinds — object events carry `pipeline` | `onboard`, synthetic
9705
+ * tracks carry `sensor`.
9706
+ */
9707
+ source: _enum([
9708
+ "pipeline",
9709
+ "onboard",
9710
+ "sensor",
9711
+ "any"
9712
+ ]).optional(),
9713
+ /**
9714
+ * Minimum identity / plate MATCH confidence in [0,1] — DISTINCT from the
9715
+ * detector `minConfidence` (that gates the object-detection score; this
9716
+ * gates the recognition/OCR match score). Fails when the subject carries
9717
+ * no label-match confidence (never guess). TRACK-END only: the confidence
9718
+ * lives on the recognition result and reaches the subject at track close.
9719
+ *
9720
+ * What it measures precisely (plumbed at track close — the closer threads
9721
+ * the value into `NcTrackClosedInfo.labelConfidence`, the same seam as
9722
+ * `importance`): the BEST recognition match confidence observed for the
9723
+ * label the track carries at close — for a face, the peak cosine similarity
9724
+ * of the ASSIGNED identity (`FaceMatch.score`, reset on an identity switch);
9725
+ * for a plate, the peak OCR read score of the best-held plate
9726
+ * (`plateText.confidence`). When BOTH a face and a plate were recognized on
9727
+ * one track the higher of the two is used. A track that ended with no
9728
+ * confident identity/plate match carries no value, so the condition fails
9729
+ * closed for it (an un-recognized subject).
9730
+ */
9731
+ minLabelConfidence: number().min(0).max(1).optional(),
9732
+ /**
9733
+ * DEVICE-EVENT only. Raw device event-type tokens (`EventFire.eventType`,
9734
+ * e.g. a doorbell `press` / `press_long`) — matched case-insensitively
9735
+ * against the token carried on the device-event subject (extracted from the
9736
+ * event-emitter runtime slice's `lastEvent.eventType`). Fails when the
9737
+ * subject carries no token. Doorbell-pulse / passive-sensor kinds emit no
9738
+ * eventType, so gate those with {@link sensorKinds} instead.
9739
+ */
9740
+ eventTypeTokens: array(string().min(1)).optional(),
9741
+ /**
9742
+ * DEVICE-EVENT only. Sensor/control taxonomy kinds (e.g. `doorbell`,
9743
+ * `contact`, `button`, `device-event`) — matched against the persisted
9744
+ * `SensorEvent.kind` (see `sensor-event-kinds.ts`). Membership is OR.
9745
+ */
9746
+ sensorKinds: array(string().min(1)).optional(),
9747
+ /**
9748
+ * PACKAGE-EVENT only. Which package phase fires the rule — `delivered`
9749
+ * (a parked parcel appeared), `picked-up` (it departed), or `both`. Fails
9750
+ * when the subject's phase does not match (a subject always carries a phase
9751
+ * on the package-event trigger).
9752
+ */
9753
+ packagePhase: _enum([
9754
+ "delivered",
9755
+ "picked-up",
9756
+ "both"
9757
+ ]).optional(),
9758
+ /**
9759
+ * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
9760
+ * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
9761
+ * listed polygon (ZoneEngine membership semantics). Evaluated only when
9762
+ * the subject carries a bbox; absent bbox ⇒ the condition FAILS.
9763
+ */
9764
+ customZones: array(MaskPolygonShapeSchema).optional(),
9765
+ /**
9766
+ * DEVICE-EVENT only. ZoneAnalytics occupancy edge — fires when a device's
9767
+ * (optionally zone/class-scoped) occupancy count crosses the configured
9768
+ * threshold and holds for `sustainSeconds`. Fail-closed on missing
9769
+ * substrate (no snapshot / missing zone). See {@link NcOccupancyCondition}.
9770
+ */
9771
+ occupancy: NcOccupancyConditionSchema.optional()
9772
+ });
9773
+ /** One delivery target: a `notification-output` Target ref + passthrough params. */
9774
+ var NcRuleTargetSchema = object({
9775
+ /** `notification-output` Target id. */
9776
+ targetId: string().min(1),
9777
+ /**
9778
+ * Per-backend passthrough. Recognized keys are mapped onto the canonical
9779
+ * Notification (`priority`, `level`, `sound`, `clickUrl`, `ttl`); the
9780
+ * degrade engine drops what the backend can't render.
9781
+ */
9782
+ params: record(string(), unknown()).optional()
9783
+ });
9784
+ /**
9785
+ * Media attachment policy (P1 still-image subset).
9786
+ * - `best` — the best AVAILABLE subject image at dispatch time (D-3).
9787
+ * - `best-matching` — the media that explains WHY the rule fired: a rule
9788
+ * matched on identities attaches the subject's `faceCrop`, one matched on
9789
+ * plates attaches the `plateCrop`; a rule with no identity/plate condition
9790
+ * (or when the specific crop is missing) degrades to `best`, then
9791
+ * `keyFrame`, then no attachment — never delaying the send. The matched
9792
+ * condition summary is frozen on the outbox row at enqueue (like the rule
9793
+ * name), so the choice never drifts from the record that fired it.
9794
+ * - `keyFrame` — the clean scene frame (no subject box).
9795
+ * - `none` — no attachment.
9796
+ */
9797
+ /**
9798
+ * WHAT THE PICTURE SHOWS — chosen explicitly, because the implicit ladders
9799
+ * conflate it with the selection strategy and betray the request: asking for
9800
+ * the clean scene frame on an object-event owner used to start at
9801
+ * `fullFrameBoxed`, i.e. the annotated frame (2026-07-30).
9802
+ * - `cropped` — the subject, tight. What "who is at the door" wants.
9803
+ * - `full` — the clean scene, no annotation. What "what is going on" wants.
9804
+ * - `boxed` — the scene WITH the detection boxes drawn, for verifying what
9805
+ * the pipeline actually saw.
9806
+ */
9807
+ var NcMediaFrameSchema = _enum([
9808
+ "cropped",
9809
+ "full",
9810
+ "boxed"
9811
+ ]);
9812
+ var NcMediaPolicySchema = object({
9813
+ attach: _enum([
9814
+ "best",
9815
+ "best-matching",
9816
+ "keyFrame",
9817
+ "none"
9818
+ ]).default("best"),
9819
+ /** What the still shows. Absent = `cropped` (the historical `best` shape). */
9820
+ frame: NcMediaFrameSchema.optional(),
9821
+ /** Crop the attached still to the rule's condition-zone bbox (padded) —
9822
+ * "show me the ZONE", not the whole scene or the subject crop. */
9823
+ zoneCrop: boolean().optional(),
9824
+ /**
9825
+ * Also attach a short GIF cut from the stream broker's clip ring around the
9826
+ * event — NOT from the recording, so the camera does not have to be
9827
+ * recording, and the window sits AROUND the moment instead of a segment
9828
+ * behind it. Fail-closed: a window the ring does not cover contributes no
9829
+ * gif, never a failed notification.
9830
+ */
9831
+ gif: boolean().optional(),
9832
+ /**
9833
+ * Also attach a short MP4 CLIP of the same cut. Same source and the same
9834
+ * fail-closed rule as `gif`. Prefer it where the backend takes video
9835
+ * (telegram, discord, zentik, webhook); backends that do not are handled by
9836
+ * the degrade engine, which drops the video and keeps the still.
9837
+ */
9838
+ clip: boolean().optional(),
9839
+ /** Seconds of footage BEFORE / AFTER the event instant. Defaults 3 / 7. */
9840
+ clipPreRollSec: number().int().min(0).max(30).optional(),
9841
+ clipPostRollSec: number().int().min(0).max(30).optional(),
9842
+ /**
9843
+ * Which stream profile the footage is cut from. Absent = the CHEAPEST
9844
+ * assigned profile: a notification is watched on a phone, so the 4K
9845
+ * rendition would burn CPU to produce a file the client downscales anyway.
9846
+ * A profile that is not assigned falls back to the cheapest, and the render
9847
+ * reports which one actually ran.
9848
+ */
9849
+ profile: CamProfileSchema.optional()
9850
+ });
9851
+ /** Throttle — cooldown survives restarts (rebuilt from the outbox on boot). */
9852
+ var NcThrottleSchema = object({
9853
+ cooldownSec: number().int().min(0).max(86400).default(60),
9854
+ /** `rule` = one shared cooldown; `rule-device` = per-camera cooldown. */
9855
+ scope: _enum(["rule", "rule-device"]).default("rule-device")
9856
+ });
9857
+ /** Client-supplied rule fields (server stamps id/createdBy/createdAt/updatedAt). */
9858
+ var NcRuleInputSchema = object({
9859
+ name: string().min(1).max(200),
9860
+ enabled: boolean().default(true),
9861
+ delivery: NcDeliverySchema,
9862
+ conditions: NcConditionsSchema.default({}),
9863
+ schedule: NcScheduleSchema.optional(),
9864
+ /** May be empty when `targetUsers` addresses at least one user — the
9865
+ * "at least one addressee" invariant is enforced by the provider, because
9866
+ * a cross-field refine here would break `NcRulePatchSchema.partial()`. */
9867
+ targets: array(NcRuleTargetSchema),
9868
+ /**
9869
+ * USERS this rule addresses in addition to `targets` (Phase 4). At fire
9870
+ * time each user fans out to the personal targets they own
9871
+ * (`config.ownerUserId`), filtered by that user's `allowedDevices` for the
9872
+ * firing camera — a user is never notified about a device they cannot open.
9873
+ * Per-user opt-outs (`disabledTargetIds`) still apply to the fanned-out
9874
+ * targets.
9875
+ */
9876
+ targetUsers: array(string()).optional(),
9877
+ media: NcMediaPolicySchema.default({ attach: "best" }),
9878
+ throttle: NcThrottleSchema.default({
9879
+ cooldownSec: 60,
9880
+ scope: "rule-device"
9881
+ }),
9882
+ /** `{{var}}` templating over camera/class/label/zones/confidence/time. */
9883
+ template: object({
9884
+ title: string().max(500).optional(),
9885
+ body: string().max(2e3).optional()
9886
+ }).optional(),
9887
+ /** Canonical notification priority ordinal (1..5); per-target overridable. */
9888
+ priority: number().int().min(1).max(5).default(3),
9889
+ /**
9890
+ * Ownership/visibility key. Absent = admin/global rule (unchanged legacy
9891
+ * behaviour, visible to all, read-only in the viewer). Present = personal
9892
+ * rule owned by this userId. Server-stamped; never trusted from a client.
9893
+ */
9894
+ ownerUserId: string().optional()
9895
+ });
9896
+ /**
9897
+ * Partial patch for `updateRule` — any subset of the input fields, plus the
9898
+ * persisted-only {@link NcRuleSchema} `disabledTargetIds` set. The latter is
9899
+ * NOT a client-authored input field (it lives on the persisted rule, not the
9900
+ * input), so it is added here explicitly to let the store's per-target opt-out
9901
+ * toggle round-trip through the shared `update` path. Viewer opt-out mutations
9902
+ * still flow through `nc.setRuleTargetEnabled` (owner-checked), never a raw
9903
+ * `updateRule` patch.
9904
+ */
9905
+ var NcRulePatchSchema = NcRuleInputSchema.partial().extend({ disabledTargetIds: array(string()).optional() });
9906
+ /** A persisted rule. */
9907
+ var NcRuleSchema = NcRuleInputSchema.extend({
9908
+ id: string(),
9909
+ /** userId of the admin who created the rule (server-stamped caller). */
9910
+ createdBy: string(),
9911
+ createdAt: number(),
9912
+ updatedAt: number(),
9913
+ /**
9914
+ * Per-target opt-out set. A targetId here is suppressed for THIS rule at
9915
+ * send time. Only a target's OWNER may add/remove its id (server-checked
9916
+ * in `nc.setRuleTargetEnabled`). Defaults to empty.
9917
+ */
9918
+ disabledTargetIds: array(string()).default([])
9919
+ });
9920
+ var NcTestResultSchema = object({
9921
+ recordId: string(),
9922
+ recordKind: _enum([
9923
+ "object-event",
9924
+ "track",
9925
+ "device-event",
9926
+ "package-event"
9927
+ ]),
9928
+ deviceId: number(),
9929
+ timestamp: number(),
9930
+ wouldFire: boolean(),
9931
+ /** Condition id that failed (first failing group), when `wouldFire` is false. */
9932
+ failedCondition: string().optional(),
9933
+ className: string().optional(),
9934
+ label: string().optional()
9935
+ });
9936
+ var NcConditionDescriptorSchema = object({
9937
+ /** Field id inside `NcConditions` (or `'schedule'` for the rule-level group). */
9938
+ id: string(),
9939
+ group: _enum([
9940
+ "scope",
9941
+ "class",
9942
+ "zones",
9943
+ "quality",
9944
+ "label",
9945
+ "schedule",
9946
+ "device",
9947
+ "package",
9948
+ "occupancy"
9949
+ ]),
9950
+ label: string(),
9951
+ /** Editor widget the UI renders — never hardcode per-condition forms. */
9952
+ valueType: _enum([
9953
+ "deviceIdList",
9954
+ "stringList",
9955
+ "number01",
9956
+ "number",
9957
+ "sourceSelect",
9958
+ "zoneSelection",
9959
+ "zoneIdList",
9960
+ "schedule",
9961
+ "plateMatcher",
9962
+ "packagePhase",
9963
+ "polygonDraw",
9964
+ "occupancy"
9965
+ ]),
9966
+ operator: _enum([
9967
+ "in",
9968
+ "notIn",
9969
+ "anyOf",
9970
+ "allOf",
9971
+ "gte",
9972
+ "fuzzyIn",
9973
+ "withinSchedule"
9974
+ ]),
9975
+ /** Which delivery kinds the condition applies to. */
9976
+ appliesTo: array(NcDeliverySchema),
9977
+ phase: string(),
9978
+ description: string().optional()
9979
+ });
9980
+ /**
9981
+ * The delivery lifecycle status of a history row — a straight read of the
9982
+ * durable outbox row's own status (single source of truth):
9983
+ * - `pending` — enqueued, in-flight or retrying with backoff
9984
+ * - `sent` — delivered (terminal)
9985
+ * - `dead` — dead-lettered after exhausting retries / a permanent
9986
+ * backend rejection / a deleted target (terminal; carries
9987
+ * the failure `error`)
9988
+ *
9989
+ * P1 has no `suppressed-quiet-hours` / `snoozed` states — those ride the P2
9990
+ * user dimension (quiet hours / snooze) and are additive when they land.
9991
+ */
9992
+ var NcHistoryStatusSchema = _enum([
9993
+ "pending",
9994
+ "sent",
9995
+ "dead"
9996
+ ]);
9997
+ /** The evaluated record kind a history row descends from (one per trigger). */
9998
+ var NcHistoryRecordKindSchema = _enum([
9999
+ "object-event",
10000
+ "track-end",
10001
+ "device-event",
10002
+ "package-event"
10003
+ ]);
10004
+ /** Subject summary frozen on the row at fire time (survives rule/record edits). */
10005
+ var NcHistorySubjectSchema = object({
10006
+ className: string(),
10007
+ label: string().optional(),
10008
+ confidence: number().optional(),
10009
+ zones: array(string()),
10010
+ timestamp: number()
10011
+ });
10012
+ /**
10013
+ * One delivery-history row. This is a read-only VIEW over the durable
10014
+ * outbox row (single source of truth — the same row the drain loop drives;
10015
+ * NO second write path, so history can never drift from delivery state).
10016
+ * The §3.2 fields map directly: `ruleId`/`targetId`/`deviceId` are columns,
10017
+ * `eventRef` is `recordKind`+`recordId`, `timestamps` are `createdAt`
10018
+ * (fire) / `updatedAt` (last transition), `status` + `error` are the
10019
+ * lifecycle. `ruleName` + `subject` are the intent snapshot frozen at
10020
+ * enqueue. `userId?` (per-recipient history) is P2 — no user dimension in
10021
+ * P1 (admin scope only).
10022
+ */
10023
+ var NcHistoryEntrySchema = object({
10024
+ /** Outbox row id — the stable dedup id `ruleId:dedupRef:targetId`. */
10025
+ id: string(),
10026
+ ruleId: string(),
10027
+ /** Rule name frozen at fire time (outlives a later rename / delete). */
10028
+ ruleName: string(),
10029
+ /** The rule urgency/trigger that produced this delivery. */
10030
+ delivery: NcDeliverySchema,
10031
+ targetId: string(),
10032
+ deviceId: number(),
10033
+ recordKind: NcHistoryRecordKindSchema,
10034
+ /** Event / track ref of the evaluated record (§3.2 `eventRef`). */
10035
+ recordId: string(),
10036
+ /** Present for track-scoped deliveries (object-event / track-end). */
10037
+ trackId: string().optional(),
10038
+ status: NcHistoryStatusSchema,
10039
+ /** Delivery attempts made so far. */
10040
+ attempts: number().int(),
10041
+ /** Fire time (outbox enqueue). */
10042
+ createdAt: number(),
10043
+ /** Last transition time (terminal for sent / dead). */
10044
+ updatedAt: number(),
10045
+ /** Failure detail — present on a `dead` row. */
10046
+ error: string().optional(),
10047
+ subject: NcHistorySubjectSchema
10048
+ });
10049
+ /**
10050
+ * Query filter for `getHistory` (spec §4.2). Every field is a narrowing
10051
+ * AND; absent = unbounded on that axis. `since`/`until` bound the fire time
10052
+ * (`createdAt`, epoch ms, inclusive). `limit` is clamped to
10053
+ * {@link NC_HISTORY_LIMIT_MAX}. `userId` (per-recipient filtering) is P2.
10054
+ */
10055
+ var NcHistoryFilterSchema = object({
10056
+ ruleId: string().optional(),
10057
+ deviceId: number().optional(),
10058
+ status: NcHistoryStatusSchema.optional(),
10059
+ since: number().optional(),
10060
+ until: number().optional(),
10061
+ limit: number().int().min(1).max(500).default(100)
10062
+ });
10063
+ 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 }), {
10064
+ kind: "mutation",
10065
+ auth: "admin",
10066
+ caller: "required"
10067
+ }), method(object({
10068
+ ruleId: string(),
10069
+ patch: NcRulePatchSchema
10070
+ }), object({ rule: NcRuleSchema }), {
10071
+ kind: "mutation",
10072
+ auth: "admin",
10073
+ caller: "required"
10074
+ }), method(object({ ruleId: string() }), object({ success: literal(true) }), {
10075
+ kind: "mutation",
10076
+ auth: "admin"
10077
+ }), method(object({
10078
+ ruleId: string(),
10079
+ enabled: boolean()
10080
+ }), object({ success: literal(true) }), {
10081
+ kind: "mutation",
10082
+ auth: "admin"
10083
+ }), method(object({
10084
+ rule: NcRuleInputSchema,
10085
+ lookbackMinutes: number().int().min(1).max(1440).default(60)
10086
+ }), object({ results: array(NcTestResultSchema) }), {
10087
+ kind: "mutation",
10088
+ auth: "admin"
10089
+ }), method(object({}), object({
10090
+ catalog: array(NcConditionDescriptorSchema),
10091
+ taxonomy: NcTaxonomySchema.optional()
10092
+ })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
10093
+ /**
10094
+ * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
10095
+ *
10096
+ * Spec: `docs/superpowers/specs/2026-07-24-nc-occupancy-timelapse-design.md`
10097
+ * §3.2/§3.3.
10098
+ *
10099
+ * Deliberately NOT a capability definition and NOT an `NcRule`:
10100
+ * - Every `NcDelivery` member is a *persisted-pipeline-record* trigger. A
10101
+ * timelapse fires on a SCHEDULE WINDOW BOUNDARY, evaluates no pipeline
10102
+ * record, and produces a video it assembled itself — so it rides no
10103
+ * delivery-enum member (the enum is frozen) and no cap method. This file is
10104
+ * a plain typed schema; it does NOT go through `npm run codegen`.
10105
+ * - It shares only the delivery leg (`notification-output.send`) and the
10106
+ * persistence/ownership patterns with the Notification Center, reusing
10107
+ * {@link NcScheduleSchema} (weekly windows, midnight-crossing, invertible)
10108
+ * and {@link NcRuleTargetSchema} (target ref + passthrough params).
10109
+ *
10110
+ * Ownership is SERVER-DERIVED. `ownerUserId` / `createdBy` / `createdAt` /
10111
+ * `updatedAt` / `id` / `lastGeneratedAt` live on the PERSISTED rule only —
10112
+ * {@link TimelapseRuleInputSchema} and {@link TimelapseRulePatchSchema} do not
10113
+ * carry them, so a forged client payload can never claim or re-own a rule
10114
+ * (Zod strips unknown keys). The store stamps them from the resolved caller.
10115
+ */
10116
+ /** `{{var}}` templating over camera/rule/time — same vocabulary as `NcRule`. */
10117
+ var TimelapseTemplateSchema = object({
10118
+ title: string().max(500).optional(),
10119
+ body: string().max(2e3).optional()
10120
+ });
10121
+ var NameField = string().min(1).max(200);
10122
+ var DeviceIdsField = array(number()).min(1);
10123
+ var CadenceSecField = number().int().min(2).max(3600);
10124
+ var FramerateField = number().int().min(1).max(60);
10125
+ var TargetsField = array(NcRuleTargetSchema).min(1);
10126
+ var PriorityField = number().int().min(1).max(5);
10127
+ /**
10128
+ * Client-supplied timelapse-rule fields. The server stamps id / createdBy /
10129
+ * createdAt / updatedAt / ownerUserId / lastGeneratedAt — none of them appear
10130
+ * here (see the ownership note above).
10131
+ */
10132
+ var TimelapseRuleInputSchema = object({
10133
+ name: NameField,
10134
+ enabled: boolean().default(true),
10135
+ /** Cameras sampled by this rule — one scratch dir + one artifact per device. */
10136
+ deviceIds: DeviceIdsField,
10137
+ /**
10138
+ * Activation window(s). REQUIRED (unlike `NcRule`, where an absent schedule
10139
+ * means "always active"): a timelapse is defined by its window boundaries —
10140
+ * open clears the scratch, close assembles and delivers.
10141
+ */
10142
+ schedule: NcScheduleSchema,
10143
+ /** Force-snapshot cadence inside the window, seconds (predecessor parity). */
10144
+ cadenceSec: CadenceSecField.default(15),
10145
+ /** Output frames per second of the assembled mp4 (predecessor parity). */
10146
+ framerate: FramerateField.default(10),
10147
+ /** `notification-output` targets the finished video/thumbnail is sent to. */
10148
+ targets: TargetsField,
10149
+ template: TimelapseTemplateSchema.optional(),
10150
+ /** Canonical notification priority ordinal (1..5); per-target overridable. */
10151
+ priority: PriorityField.default(3)
10152
+ });
10153
+ object({
10154
+ name: NameField.optional(),
10155
+ enabled: boolean().optional(),
10156
+ deviceIds: DeviceIdsField.optional(),
10157
+ schedule: NcScheduleSchema.optional(),
10158
+ cadenceSec: CadenceSecField.optional(),
10159
+ framerate: FramerateField.optional(),
10160
+ targets: TargetsField.optional(),
10161
+ template: TimelapseTemplateSchema.nullable().optional(),
10162
+ priority: PriorityField.optional()
10163
+ });
10164
+ TimelapseRuleInputSchema.extend({
10165
+ id: string(),
10166
+ /**
10167
+ * Ownership/visibility key. Absent = admin/global rule (visible to all).
10168
+ * Present = personal rule owned by this userId. Server-stamped from the
10169
+ * resolved caller; never trusted from a client payload.
10170
+ */
10171
+ ownerUserId: string().optional(),
10172
+ /**
10173
+ * Epoch-ms of the last successful generation — the 1-hour re-generation
10174
+ * guard's durable state (predecessor parity). Absent = never generated.
10175
+ */
10176
+ lastGeneratedAt: number().optional(),
10177
+ /** userId of the caller who created the rule (server-stamped). */
10178
+ createdBy: string(),
10179
+ createdAt: number(),
10180
+ updatedAt: number()
10181
+ });
10182
+ /**
10183
+ * Generic device-level status snapshot. Auto-registered by `BaseDevice`
10184
+ * for every device, regardless of provider — the kernel needs a uniform
10185
+ * cap-keyed slice for the basic device flags every consumer expects to
10186
+ * read across processes (the `online` flag in particular). Driver-specific
10187
+ * caps (`battery`, `doorbell`, …) carry their domain-specific state on
10188
+ * their own slices.
10189
+ *
10190
+ * Pattern is identical to `battery`: schema-bearing `runtimeState`,
10191
+ * empty `methods`, single change event. Reads land at
10192
+ * `runtimeState.getCapState('device-status')`; writes at
10193
+ * `runtimeState.setCapState('device-status', …)`. Cross-process
10194
+ * consumers reach the same data via the `device-state` cap router
10195
+ * (`getCapSlice({deviceId, capName: 'device-status'})`).
10196
+ */
10197
+ var DeviceStatusSchema = object({
10198
+ /**
10199
+ * Device-level liveness. Drivers flip via `markOnline(boolean)` on
10200
+ * `BaseDevice`. Provider semantics vary — RTSP aggregates broker
10201
+ * stream-health, Reolink reads firmware push events, ONVIF tracks
10202
+ * ping responses. This cap intentionally does NOT prescribe which
10203
+ * signal drives the flag.
10204
+ */
10205
+ online: boolean(),
10206
+ /** Ms epoch of the last `online` transition. Lets consumers tell
10207
+ * apart "just came online" from "still online". */
10208
+ lastChangedAt: number()
10209
+ });
10210
+ var deviceStatusCapability = {
10211
+ name: "device-status",
10212
+ scope: "device",
10213
+ deviceNative: true,
10214
+ mode: "singleton",
10215
+ methods: {},
10216
+ events: {
10217
+ /** Emitted when `online` transitions. Mirrors the semantics of
10218
+ * `battery.onStatusChanged`. */
10219
+ onStatusChanged: { data: object({
10220
+ deviceId: number(),
10221
+ status: DeviceStatusSchema
10222
+ }) } },
10223
+ status: {
10224
+ schema: DeviceStatusSchema,
10225
+ kind: "push"
10226
+ },
10227
+ runtimeState: DeviceStatusSchema
10228
+ };
10229
+ /**
10230
+ * Per-device feature/identity probe slice. Holds the runtime-resolved
10231
+ * truth about what a device CAN do — which the kernel uses to:
10232
+ * 1. Reconcile accessory children (hub-children spawn siren/floodlight/PIR
10233
+ * based on what the firmware actually advertises).
10234
+ * 2. Compute the public `features: DeviceFeature[]` array surfaced via
10235
+ * `device-manager.listAll`.
10236
+ * 3. Decide which optional caps (PTZ, intercom, doorbell, battery, …)
10237
+ * to register on the device's capability surface.
10238
+ *
10239
+ * Auto-registered by `BaseDevice` for every device. Drivers populate the
10240
+ * slice from `onProbe()` (kernel calls it once after register, before
10241
+ * accessory reconciliation). Consumers read via:
10242
+ * `runtimeState.getCapState<FeatureProbeStatus>('feature-probe')`
10243
+ *
10244
+ * `flags` is an open record so each driver carries its own keys without
10245
+ * a centralized schema bottleneck — Reolink writes `hasPtz/hasIntercom`,
10246
+ * Hikvision writes `hasSupplementalLight/hasAlarmIo`, etc.
10247
+ *
10248
+ * Replaces the older driver-local `deviceCache.has*` blob: the per-device
10249
+ * config is for operator-edited overrides + UI snapshots; runtime probe
10250
+ * results belong in runtime-state where the kernel handles persistence,
10251
+ * cross-process mirroring, and reactive updates.
10252
+ */
10253
+ var FeatureProbeStatusSchema = object({
10254
+ /**
10255
+ * Driver-specific flag bag. Each driver picks its own key names — the
10256
+ * cap deliberately does NOT enforce a closed enum here. Reolink keys:
10257
+ * `hasPtz`, `hasIntercom`, `hasDoorbell`, `hasFloodlight`, `hasSiren`,
10258
+ * `hasPirSensor`, `hasAutotrack`, `hasBattery`. Hikvision keys:
10259
+ * `hasSupplementalLight`, `lightHasWhiteLight`, `hasAlarmIo`, `hasPtz`.
10260
+ */
10261
+ flags: record(string(), unknown()),
10262
+ /**
10263
+ * Coarse driver-classification — lets cross-process consumers tell apart
10264
+ * cameras / battery-cams / NVRs without re-running the probe. `null`
10265
+ * before the first probe completes.
10266
+ */
10267
+ deviceType: string().nullable(),
10268
+ /** Camera/firmware model string. `null` when the firmware doesn't expose it. */
10269
+ model: string().nullable(),
10270
+ /** Channel count for NVR/Hub devices; `1` for standalone cameras; `null` pre-probe. */
10271
+ channelCount: number().nullable(),
10272
+ /**
10273
+ * Ms epoch of the last SUCCESSFUL probe. `0` before the first probe
10274
+ * completes — drivers' `getAccessoryChildren()` should treat zero as
10275
+ * "probe not done yet, return empty" so accessories aren't spawned
10276
+ * before the firmware is queried.
10277
+ */
10278
+ lastProbedAt: number(),
10279
+ /**
10280
+ * Framework convention: every runtime-state slice carries this for the
10281
+ * createRuntimeStateBridge stale-check helper. We keep it in sync with
10282
+ * `lastProbedAt` on every write.
10283
+ */
10284
+ lastFetchedAt: number()
10285
+ });
10286
+ var featureProbeCapability = {
10287
+ name: "feature-probe",
10288
+ scope: "device",
10289
+ deviceNative: true,
10290
+ mode: "singleton",
10291
+ methods: {},
10292
+ events: {
10293
+ /** Fires whenever a fresh probe completes (kernel-driven `reprobe()`
10294
+ * or driver-initiated re-detect after a state change). */
10295
+ onProbeChanged: { data: object({
10296
+ deviceId: number(),
10297
+ status: FeatureProbeStatusSchema
10298
+ }) } },
10299
+ status: {
10300
+ schema: FeatureProbeStatusSchema,
10301
+ kind: "push"
10302
+ },
10303
+ runtimeState: FeatureProbeStatusSchema
10304
+ };
10305
+ /**
10306
+ * Multi-metric air-quality slice. Covers CO₂, total VOCs, particulate
10307
+ * matter at PM2.5 / PM10, and a derived AQI index — all optional so
10308
+ * a single-metric source populates only what it observes. Mirrors
10309
+ * the HA `sensor` device_class set (`co2`, `volatile_organic_compounds`,
10310
+ * `pm25`, `pm10`, `aqi`) collapsed into one cap because a typical
10311
+ * air-quality node reports several of these together; modelling them
10312
+ * as siblings keeps a single timestamp + one slice subscription.
10313
+ */
10314
+ var AirQualitySensorStatusSchema = object({
10315
+ /** Carbon dioxide concentration in ppm. */
10316
+ co2Ppm: number().min(0).optional(),
10317
+ /** Total volatile organic compounds in ppb. */
10318
+ vocPpb: number().min(0).optional(),
10319
+ /** Particulate matter ≤ 2.5 μm in µg/m³. */
10320
+ pm25: number().min(0).optional(),
10321
+ /** Particulate matter ≤ 10 μm in µg/m³. */
10322
+ pm10: number().min(0).optional(),
9575
10323
  /** Composite AQI value (typically 0..500). */
9576
10324
  aqi: number().optional(),
9577
10325
  /** Ms epoch when the slice was last updated. */
@@ -10350,6 +11098,28 @@ method(object({
10350
11098
  }), object({ success: literal(true) }), {
10351
11099
  kind: "mutation",
10352
11100
  auth: "admin"
11101
+ }), method(object({
11102
+ deviceId: number(),
11103
+ /** Absent = the LOWEST assigned profile — a notification attachment is
11104
+ * watched on a phone, and the cheap rendition is the right default. */
11105
+ profile: CamProfileSchema.optional(),
11106
+ aroundMs: number(),
11107
+ preRollSec: number().min(0).max(20).default(3),
11108
+ postRollSec: number().min(0).max(20).default(5),
11109
+ format: _enum(["gif", "mp4"]).default("gif"),
11110
+ maxWidth: number().int().min(120).max(1920).default(480),
11111
+ /** GIF only — MP4 keeps the source cadence. */
11112
+ fps: number().int().min(1).max(15).default(5)
11113
+ }), object({
11114
+ base64: string(),
11115
+ mime: string(),
11116
+ bytes: number().int(),
11117
+ /** The profile actually rendered (what the default resolved to). */
11118
+ profile: CamProfileSchema,
11119
+ durationMs: number()
11120
+ }), {
11121
+ kind: "mutation",
11122
+ auth: "admin"
10353
11123
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10354
11124
  probed: boolean(),
10355
11125
  summary: string()
@@ -13628,67 +14398,6 @@ var motionTriggerCapability = {
13628
14398
  runtimeState: MotionTriggerRuntimeStateSchema
13629
14399
  };
13630
14400
  /**
13631
- * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
13632
- * motion-zones, and the detection zones/lines editor all speak this one
13633
- * language so a single drawing-plane editor and the providers stay
13634
- * decoupled from each cap's storage.
13635
- *
13636
- * All coordinates are normalized 0..1 of the camera frame (top-left
13637
- * origin). Each cap composes the SUBSET of shape kinds it supports and
13638
- * advertises it via `supportedShapes` in its `getOptions`.
13639
- */
13640
- /** A normalized 0..1 point (top-left origin). */
13641
- var MaskPointSchema = object({
13642
- x: number(),
13643
- y: number()
13644
- });
13645
- /** Axis-aligned rectangle (normalized 0..1). */
13646
- var MaskRectShapeSchema = object({
13647
- kind: literal("rect"),
13648
- x: number(),
13649
- y: number(),
13650
- width: number(),
13651
- height: number()
13652
- });
13653
- /** Free polygon — an ordered list of normalized vertices (≥3). */
13654
- var MaskPolygonShapeSchema = object({
13655
- kind: literal("polygon"),
13656
- points: array(MaskPointSchema)
13657
- });
13658
- /** Boolean cell grid — row-major, length === gridWidth*gridHeight. */
13659
- var MaskGridShapeSchema = object({
13660
- kind: literal("grid"),
13661
- gridWidth: number(),
13662
- gridHeight: number(),
13663
- cells: array(boolean())
13664
- });
13665
- discriminatedUnion("kind", [
13666
- MaskRectShapeSchema,
13667
- MaskPolygonShapeSchema,
13668
- MaskGridShapeSchema,
13669
- object({
13670
- kind: literal("line"),
13671
- points: array(MaskPointSchema)
13672
- })
13673
- ]);
13674
- /** Every shape-kind discriminant, for `supportedShapes` advertisement. */
13675
- var MaskShapeKindSchema = _enum([
13676
- "rect",
13677
- "polygon",
13678
- "grid",
13679
- "line"
13680
- ]);
13681
- /** Polygon vertex bounds when a cap supports 'polygon' (e.g. Hikvision {min:4,max:4}). */
13682
- var MaskPolygonVerticesSchema = object({
13683
- min: number(),
13684
- max: number()
13685
- });
13686
- /** Grid dimensions when a cap supports 'grid'. */
13687
- var MaskGridDimsSchema = object({
13688
- width: number(),
13689
- height: number()
13690
- });
13691
- /**
13692
14401
  * Motion-zones share the same MaskShape vocabulary as privacy-mask — the
13693
14402
  * on-camera motion-detection mask is a single `grid` region (a row-major
13694
14403
  * boolean cell lattice the camera's onboard VMD evaluates). Composing it as
@@ -17144,6 +17853,55 @@ method(object({
17144
17853
  password: string()
17145
17854
  }), AuthResultSchema.nullable(), { kind: "mutation" }), method(object({ state: string() }), string()), method(record(string(), string()), AuthResultSchema, { kind: "mutation" }), method(object({ token: string() }), AuthResultSchema.nullable());
17146
17855
  /**
17856
+ * A live terminal session hosted by the provider addon. Output and input do
17857
+ * NOT flow through the capability — they use the addon data plane
17858
+ * (`GET /addon/terminal/<id>/out` SSE, `POST /addon/terminal/<id>/in`) because
17859
+ * terminal output must be ordered and lossless. The event bus is telemetry and
17860
+ * may drop chunks ([D8]), and a dropped chunk desynchronises the vt parser
17861
+ * permanently until a full repaint. The capability owns only lifecycle.
17862
+ */
17863
+ var TerminalSessionInfoSchema = object({
17864
+ /** Opaque session id minted by the provider on `openSession`. */
17865
+ sessionId: string(),
17866
+ /** The pre-declared profile this session runs (never a free-form command). */
17867
+ profileId: string(),
17868
+ /** Human-readable profile label for the UI session list. */
17869
+ label: string(),
17870
+ cols: number().int().positive(),
17871
+ rows: number().int().positive(),
17872
+ /** ms-epoch the session's pty was spawned. */
17873
+ startedAt: number()
17874
+ });
17875
+ /**
17876
+ * A profile the operator may open — a pre-declared, allowlisted program
17877
+ * (`monitor` → `btm`). The capability accepts only these ids; a free-form
17878
+ * command string would be remote code execution as the server's user, so it is
17879
+ * deliberately not part of the contract.
17880
+ */
17881
+ var TerminalProfileInfoSchema = object({
17882
+ profileId: string(),
17883
+ label: string(),
17884
+ description: string().optional()
17885
+ });
17886
+ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }), method(object({
17887
+ profileId: string(),
17888
+ cols: number().int().positive(),
17889
+ rows: number().int().positive()
17890
+ }), TerminalSessionInfoSchema, {
17891
+ kind: "mutation",
17892
+ auth: "admin"
17893
+ }), method(object({
17894
+ sessionId: string(),
17895
+ cols: number().int().positive(),
17896
+ rows: number().int().positive()
17897
+ }), _void(), {
17898
+ kind: "mutation",
17899
+ auth: "admin"
17900
+ }), method(object({ sessionId: string() }), _void(), {
17901
+ kind: "mutation",
17902
+ auth: "admin"
17903
+ });
17904
+ /**
17147
17905
  * Orchestrator-side destination metadata. The orchestrator computes
17148
17906
  * `id = <addonId>:<subId>` from its provider lookup so consumers
17149
17907
  * (admin UI, restore flow) see one canonical key.
@@ -17244,11 +18002,53 @@ var LocationStatSchema = object({
17244
18002
  fileCount: number(),
17245
18003
  present: boolean()
17246
18004
  });
18005
+ /**
18006
+ * A backup schedule — the N:M "entry" that binds one cron cadence to a
18007
+ * SET of destination locations. Supersedes the per-location cron on
18008
+ * `BackupDestinationPolicy`: an operator creates a schedule, picks the
18009
+ * `backups` locations it should write to, and the orchestrator fans a
18010
+ * single archive out to all of them when the cron fires.
18011
+ *
18012
+ * `retentionCount` is per-schedule (D-decision 2026-07-28): every
18013
+ * location targeted by this schedule keeps this many archives from
18014
+ * this schedule's runs.
18015
+ *
18016
+ * `dataSources` optionally narrows which top-level state locations
18017
+ * (db, addons, tls, …) are archived; omitted = the orchestrator's
18018
+ * default full set.
18019
+ */
18020
+ var BackupScheduleSchema = object({
18021
+ /** Stable id. Generated by the orchestrator on first upsert if absent. */
18022
+ id: string(),
18023
+ /** Operator-facing display name. */
18024
+ label: string(),
18025
+ /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
18026
+ cron: string(),
18027
+ /** Master on/off toggle for the whole schedule. */
18028
+ enabled: boolean(),
18029
+ /** `backups`-location ids this schedule writes to (fan-out set). */
18030
+ locationIds: array(string()).readonly(),
18031
+ /** Archives kept per targeted location for this schedule. */
18032
+ retentionCount: number().int().min(1).max(1e3),
18033
+ /** Optional subset of source locations to include; omitted = all. */
18034
+ dataSources: array(string()).readonly().optional(),
18035
+ /** ms-epoch of last successful run. */
18036
+ lastRunAt: number().optional(),
18037
+ /** ms-epoch of next computed firing (read-only, filled on list). */
18038
+ nextRunAt: number().optional()
18039
+ });
17247
18040
  method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
17248
18041
  /** Subset of registered `backup-destination` addon ids to write to. */
17249
18042
  destinations: array(string()).optional(),
17250
18043
  locations: array(string()).optional(),
17251
- label: string().optional()
18044
+ label: string().optional(),
18045
+ /**
18046
+ * Per-run retention override applied to every targeted
18047
+ * destination. Used by schedule-driven runs (per-entry
18048
+ * retention). Omitted = each destination's own policy
18049
+ * retention (manual runs).
18050
+ */
18051
+ retentionCount: number().int().min(1).max(1e3).optional()
17252
18052
  }).optional(), array(BackupEntrySchema).readonly(), {
17253
18053
  kind: "mutation",
17254
18054
  auth: "admin"
@@ -17297,7 +18097,21 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
17297
18097
  ok: boolean(),
17298
18098
  error: string().optional(),
17299
18099
  nextRuns: array(number()).readonly()
17300
- }));
18100
+ })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
18101
+ id: string().optional(),
18102
+ label: string(),
18103
+ cron: string(),
18104
+ enabled: boolean(),
18105
+ locationIds: array(string()).readonly(),
18106
+ retentionCount: number().int().min(1).max(1e3),
18107
+ dataSources: array(string()).readonly().optional()
18108
+ }), BackupScheduleSchema, {
18109
+ kind: "mutation",
18110
+ auth: "admin"
18111
+ }), method(object({ id: string() }), _void(), {
18112
+ kind: "mutation",
18113
+ auth: "admin"
18114
+ });
17301
18115
  /**
17302
18116
  * `broker` — unified pub/sub broker registry, system-scoped collection.
17303
18117
  *
@@ -18992,997 +19806,539 @@ var MetricsGpuInfoSchema = object({
18992
19806
  model: string(),
18993
19807
  memoryUsedBytes: number(),
18994
19808
  memoryTotalBytes: number(),
18995
- temperature: number().nullable()
18996
- });
18997
- var ProcessResourceInfoSchema = object({
18998
- openFds: number(),
18999
- threadCount: number(),
19000
- activeHandles: number(),
19001
- activeRequests: number()
19002
- });
19003
- var PressureAvgsSchema = object({
19004
- avg10: number(),
19005
- avg60: number(),
19006
- avg300: number()
19007
- });
19008
- var PressureInfoSchema = object({
19009
- some: PressureAvgsSchema,
19010
- full: PressureAvgsSchema.nullable()
19011
- });
19012
- var SystemResourceSnapshotSchema = object({
19013
- cpu: CpuBreakdownSchema,
19014
- memory: MemoryInfoSchema,
19015
- gpu: MetricsGpuInfoSchema.nullable(),
19016
- network: NetworkIoSnapshotSchema,
19017
- disk: DiskIoSnapshotSchema,
19018
- pressure: object({
19019
- cpu: PressureInfoSchema.nullable(),
19020
- memory: PressureInfoSchema.nullable(),
19021
- io: PressureInfoSchema.nullable()
19022
- }),
19023
- process: ProcessResourceInfoSchema,
19024
- cpuTemperature: number().nullable(),
19025
- timestampMs: number()
19026
- });
19027
- var DiskSpaceInfoSchema = object({
19028
- path: string(),
19029
- totalBytes: number(),
19030
- usedBytes: number(),
19031
- availableBytes: number(),
19032
- percent: number()
19033
- });
19034
- var PidResourceStatsSchema = object({
19035
- pid: number(),
19036
- cpu: number(),
19037
- memory: number(),
19038
- /**
19039
- * Private (anonymous) resident bytes — the per-process V8 heap + native
19040
- * allocations NOT shared with other processes (Linux RssAnon). This is the
19041
- * "real" per-runner cost; summing it across runners is meaningful, unlike
19042
- * `memory` (RSS), which double-counts the shared mmap'd framework code.
19043
- * Undefined where /proc is unavailable (e.g. macOS).
19044
- */
19045
- privateBytes: number().optional(),
19046
- /**
19047
- * Shared file-backed resident bytes (Linux RssFile) — mmap'd framework/lib
19048
- * code shared copy-on-write across runners. Undefined on macOS.
19049
- */
19050
- sharedBytes: number().optional()
19051
- });
19052
- var AddonInstanceSchema = object({
19053
- addonId: string(),
19054
- nodeId: string(),
19055
- role: _enum(["hub", "worker"]),
19056
- pid: number(),
19057
- state: _enum([
19058
- "starting",
19059
- "running",
19060
- "stopping",
19061
- "stopped",
19062
- "crashed"
19063
- ]),
19064
- uptimeSec: number()
19065
- });
19066
- var NodeProcessSchema = object({
19067
- pid: number(),
19068
- ppid: number(),
19069
- pgid: number(),
19070
- classification: _enum([
19071
- "root",
19072
- "managed",
19073
- "system",
19074
- "ghost"
19075
- ]),
19076
- /** `$process` addon binding when `managed`, else null. */
19077
- addonId: string().nullable(),
19078
- /** Kernel-reported nodeId when the process is a known agent/worker. */
19079
- nodeId: string().nullable(),
19080
- /** Truncated command line. */
19081
- command: string(),
19082
- cpuPercent: number(),
19083
- memoryRssBytes: number(),
19084
- /** Wall-clock uptime (seconds). Parsed from `ps etime`. */
19085
- uptimeSec: number(),
19086
- /** True when ancestor walk reaches `ppid=1` (reparented to init/launchd). */
19087
- orphaned: boolean()
19088
- });
19089
- var KillProcessInputSchema = object({
19090
- pid: number(),
19091
- /** Force = SIGKILL. Default is SIGTERM. */
19092
- force: boolean().optional()
19093
- });
19094
- var KillProcessResultSchema = object({
19095
- success: boolean(),
19096
- reason: string().optional(),
19097
- signal: _enum(["SIGTERM", "SIGKILL"]).optional()
19098
- });
19099
- var DumpHeapSnapshotInputSchema = object({
19100
- /** The addon whose runner should dump a heap snapshot. */
19101
- addonId: string() });
19102
- var DumpHeapSnapshotResultSchema = object({
19103
- success: boolean(),
19104
- /** Path of the written .heapsnapshot inside the runner's container/host. */
19105
- path: string().optional(),
19106
- /** Process pid that was signalled. */
19107
- pid: number().optional(),
19108
- reason: string().optional()
19109
- });
19110
- var SystemMetricsSchema = object({
19111
- cpuPercent: number(),
19112
- memoryPercent: number(),
19113
- memoryUsedMB: number(),
19114
- memoryTotalMB: number(),
19115
- diskPercent: number().optional(),
19116
- temperature: number().optional(),
19117
- gpuPercent: number().optional(),
19118
- gpuMemoryPercent: number().optional()
19119
- });
19120
- method(_void(), SystemResourceSnapshotSchema), method(_void(), SystemResourceSnapshotSchema.nullable()), method(_void(), SystemMetricsSchema), method(object({ dirPath: string() }), DiskSpaceInfoSchema), method(_void(), MetricsGpuInfoSchema.nullable()), method(_void(), number().nullable()), method(object({ pids: array(number()) }), array(PidResourceStatsSchema)), method(_void(), array(AddonInstanceSchema).readonly()), method(object({ addonId: string() }), PidResourceStatsSchema.nullable()), method(_void(), array(NodeProcessSchema).readonly()), method(KillProcessInputSchema, KillProcessResultSchema, {
19121
- kind: "mutation",
19122
- auth: "admin"
19123
- }), method(DumpHeapSnapshotInputSchema, DumpHeapSnapshotResultSchema, {
19124
- kind: "mutation",
19125
- auth: "admin"
19126
- });
19127
- method(object({
19128
- sourceUrl: string(),
19129
- metadata: ModelConvertMetadataSchema,
19130
- targets: array(ConvertTargetSchema).min(1).readonly(),
19131
- calibrationRef: string().optional(),
19132
- sessionId: string().optional()
19133
- }), ConvertResultSchema, {
19134
- kind: "mutation",
19135
- auth: "admin",
19136
- timeoutMs: 6e5
19137
- });
19138
- method(object({
19139
- nodeId: string(),
19140
- modelId: string(),
19141
- format: _enum(MODEL_FORMATS),
19142
- entry: ModelCatalogEntrySchema
19143
- }), object({
19144
- ok: boolean(),
19145
- /** sha256 of the staged tarball (empty for a hub-local no-op). */
19146
- sha256: string(),
19147
- bytes: number(),
19148
- /** The target node's modelsDir the artifact landed in. */
19149
- path: string()
19150
- }), {
19151
- kind: "mutation",
19152
- auth: "admin"
19153
- });
19154
- /**
19155
- * `mqtt-broker` — broker-registry cap.
19156
- *
19157
- * NOT a pub/sub proxy. The cap exposes (a) a registry of configured
19158
- * MQTT brokers (external + optionally an embedded `aedes`-backed one)
19159
- * and (b) the connection details a consumer addon needs to spin up
19160
- * its OWN `mqtt.js` client.
19161
- *
19162
- * Why: pub/sub routing over the system event-bus loses fidelity
19163
- * (callback shape, QoS guarantees, will/retain semantics) and adds
19164
- * refcount bookkeeping that addons would rather own themselves. The
19165
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
19166
- * features anyway — give it the connection config, get out of the way.
19167
- *
19168
- * Consumer flow:
19169
- * const cfg = await ctx.api.mqttBroker.getBrokerConfig({ id })
19170
- * const client = mqtt.connect(cfg.url, { username: cfg.username, … })
19171
- * client.subscribe('zigbee2mqtt/+')
19172
- *
19173
- * Collection mode: multiple brokers (e.g. one local mosquitto + one
19174
- * cloud bridge). The "embedded" entry (when present) is just another
19175
- * broker in the registry — its lifecycle is owned by the addon that
19176
- * spawned it.
19177
- */
19178
- var BrokerKindSchema = _enum(["external", "embedded"]);
19179
- /**
19180
- * Broker live-probe status.
19181
- *
19182
- * - `connected` — last probe completed a clean CONNACK
19183
- * - `disconnected` — no probe has run yet (cold cache)
19184
- * - `auth-failed` — CONNACK refused with auth error (RC 4 / 5)
19185
- * - `unreachable` — TCP connect timed out / refused
19186
- * - `tls-error` — TLS handshake failed (cert / SNI / cipher)
19187
- */
19188
- var BrokerStatusSchema$1 = _enum([
19189
- "connected",
19190
- "disconnected",
19191
- "auth-failed",
19192
- "unreachable",
19193
- "tls-error"
19194
- ]);
19195
- var BrokerInfoSchema = object({
19196
- id: string(),
19197
- name: string(),
19198
- url: string(),
19199
- kind: BrokerKindSchema,
19200
- status: BrokerStatusSchema$1,
19201
- latencyMs: number().nullable(),
19202
- error: string().optional(),
19203
- /** Embedded brokers only: number of MQTT clients currently connected. */
19204
- connectedClients: number().int().nonnegative().optional(),
19205
- /** Epoch ms of the last live probe (external) or aedes snapshot (embedded). */
19206
- lastCheckedAt: number().optional()
19207
- });
19208
- /**
19209
- * Connection details — what a consumer needs to call
19210
- * `mqtt.connect(url, options)`. We split URL + credentials so the
19211
- * consumer can pass them as `mqtt.connect(url, { username, password })`
19212
- * instead of stuffing creds into the URL (which leaks them into logs).
19213
- */
19214
- var BrokerConnectionDetailsSchema = object({
19215
- url: string(),
19216
- username: string().optional(),
19217
- password: string().optional(),
19218
- /**
19219
- * Suggested prefix for `clientId`. Each consumer should suffix this
19220
- * with its own discriminator (addon id, instance id) so reconnects
19221
- * don't kick each other off (MQTT spec: clientId must be unique per
19222
- * broker).
19223
- */
19224
- clientIdPrefix: string().optional()
19225
- });
19226
- var AddBrokerInputSchema = object({
19227
- name: string().min(1),
19228
- url: string().regex(/^(mqtt|mqtts|ws|wss):\/\//, "URL must start with mqtt(s):// or ws(s)://"),
19229
- username: string().optional(),
19230
- password: string().optional(),
19231
- clientIdPrefix: string().optional()
19809
+ temperature: number().nullable()
19232
19810
  });
19233
- var AddBrokerResultSchema = object({ id: string() });
19234
- var IdInputSchema = object({ id: string() });
19235
- var TestResultSchema$1 = discriminatedUnion("ok", [object({
19236
- ok: literal(true),
19237
- latencyMs: number()
19238
- }), object({
19239
- ok: literal(false),
19240
- error: string()
19241
- })]);
19242
- var StartEmbeddedInputSchema = object({
19243
- port: number().int().min(1).max(65535).default(1883),
19244
- /** Allow anonymous connect (no username/password). Default: false. */
19245
- allowAnonymous: boolean().default(false),
19246
- /** Optional shared username/password for clients. */
19247
- username: string().optional(),
19248
- password: string().optional()
19811
+ var ProcessResourceInfoSchema = object({
19812
+ openFds: number(),
19813
+ threadCount: number(),
19814
+ activeHandles: number(),
19815
+ activeRequests: number()
19249
19816
  });
19250
- var StartEmbeddedResultSchema = object({
19251
- id: string(),
19252
- url: string()
19817
+ var PressureAvgsSchema = object({
19818
+ avg10: number(),
19819
+ avg60: number(),
19820
+ avg300: number()
19253
19821
  });
19254
- var StatusSchema = object({
19255
- brokerCount: number(),
19256
- embeddedRunning: boolean()
19822
+ var PressureInfoSchema = object({
19823
+ some: PressureAvgsSchema,
19824
+ full: PressureAvgsSchema.nullable()
19257
19825
  });
19258
- method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema$1, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
19259
- var NetworkEndpointSchema = object({
19260
- url: string(),
19261
- hostname: string(),
19262
- port: number(),
19263
- protocol: _enum(["http", "https"])
19826
+ var SystemResourceSnapshotSchema = object({
19827
+ cpu: CpuBreakdownSchema,
19828
+ memory: MemoryInfoSchema,
19829
+ gpu: MetricsGpuInfoSchema.nullable(),
19830
+ network: NetworkIoSnapshotSchema,
19831
+ disk: DiskIoSnapshotSchema,
19832
+ pressure: object({
19833
+ cpu: PressureInfoSchema.nullable(),
19834
+ memory: PressureInfoSchema.nullable(),
19835
+ io: PressureInfoSchema.nullable()
19836
+ }),
19837
+ process: ProcessResourceInfoSchema,
19838
+ cpuTemperature: number().nullable(),
19839
+ timestampMs: number()
19264
19840
  });
19265
- var NetworkAccessStatusSchema = object({
19266
- connected: boolean(),
19267
- endpoint: NetworkEndpointSchema.nullable(),
19268
- error: string().optional()
19841
+ var DiskSpaceInfoSchema = object({
19842
+ path: string(),
19843
+ totalBytes: number(),
19844
+ usedBytes: number(),
19845
+ availableBytes: number(),
19846
+ percent: number()
19269
19847
  });
19270
- /**
19271
- * Optional, richer endpoint shape returned by providers that expose
19272
- * MORE than one ingress concurrently (Tailscale Ingress with mixed
19273
- * serve+funnel rules, future ngrok multi-tunnel, …). Each entry carries
19274
- * the originating provider config (mode + sourcePort) so the
19275
- * orchestrator UI can label rows distinctly. Providers that expose only
19276
- * one endpoint just omit `listEndpoints` from their provider impl.
19277
- */
19278
- var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
19848
+ var PidResourceStatsSchema = object({
19849
+ pid: number(),
19850
+ cpu: number(),
19851
+ memory: number(),
19279
19852
  /**
19280
- * Stable id within the provider typically `<mode>-<sourcePort>` so
19281
- * the orchestrator can dedupe across `listEndpoints` polls.
19853
+ * Private (anonymous) resident bytes — the per-process V8 heap + native
19854
+ * allocations NOT shared with other processes (Linux RssAnon). This is the
19855
+ * "real" per-runner cost; summing it across runners is meaningful, unlike
19856
+ * `memory` (RSS), which double-counts the shared mmap'd framework code.
19857
+ * Undefined where /proc is unavailable (e.g. macOS).
19282
19858
  */
19283
- id: string(),
19284
- /** Operator-facing label (mirrors `MeshEndpoint.label`). */
19285
- label: string(),
19286
- /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
19287
- mode: string().optional(),
19288
- /** Originating local port the ingress fronts (informational). */
19289
- sourcePort: number().optional()
19290
- });
19291
- method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
19292
- /**
19293
- * notification-output — canonical, capability-gated notification delivery.
19294
- *
19295
- * Apprise-derived model (see
19296
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
19297
- * callers emit ONE canonical `Notification`; each provider declares a
19298
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
19299
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
19300
- * message to what the kind supports — callers never special-case a service.
19301
- *
19302
- * DESIGN DECISIONS (locked):
19303
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
19304
- * `setTargetEnabled`), each provider persisting via the `settings-store`
19305
- * cap. Rationale: the admin UI needs one uniform surface across the
19306
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
19307
- * alternative would fork the UI per addon and cannot host the
19308
- * discovery→adopt flow.
19309
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
19310
- * the generated cap-mount auto-`concatCollection`-fans them across every
19311
- * registered provider (notifiers addon + HA addon) so one catalog is
19312
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
19313
- * `addonId` the generated collection router extracts from the call input.
19314
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
19315
- * transformer) + UDS MsgPack both round-trip typed arrays — already used by
19316
- * `storage` / `storage-provider` / `recording` caps over the same path. No
19317
- * base64 fallback needed.
19318
- *
19319
- * TODO (deferred, closed-set change — separate decision): add
19320
- * `providerKind: 'notify'` so notification providers surface on the unified
19321
- * admin "Integrations" page.
19322
- */
19323
- /**
19324
- * Zentik-derived typed-media enum — the superset across every kind. Each
19325
- * adapter picks what it supports and the degrade engine filters the rest.
19326
- */
19327
- var AttachmentMediaTypeSchema = _enum([
19328
- "image",
19329
- "video",
19330
- "gif",
19331
- "audio",
19332
- "icon"
19333
- ]);
19334
- /**
19335
- * A single attachment. Exactly one of `url` (remote source, most adapters
19336
- * prefer this) or `bytes` (inline source; required for Pushover-style
19337
- * bytes-only kinds) MUST be present — the degrade engine expresses a
19338
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
19339
- */
19340
- var AttachmentSchema = object({
19341
- mediaType: AttachmentMediaTypeSchema,
19342
- url: string().optional(),
19343
- bytes: _instanceof(Uint8Array).optional(),
19344
- mime: string().optional(),
19345
- name: string().optional()
19346
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
19347
- var NotificationFormatSchema = _enum([
19348
- "text",
19349
- "markdown",
19350
- "html"
19351
- ]);
19352
- /** A single tap-through action button. */
19353
- var NotificationActionSchema = object({
19354
- id: string(),
19355
- label: string(),
19356
- url: string().optional()
19357
- });
19358
- /**
19359
- * The canonical notification. `body` is the only hard field (Apprise model).
19360
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
19361
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
19362
- * the adapter maps this ordinal onto its native level. `level?` is an
19363
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
19364
- * `priority` for that one target.
19365
- */
19366
- var NotificationSchema = object({
19367
- body: string(),
19368
- title: string().optional(),
19369
- format: NotificationFormatSchema.default("text"),
19370
- priority: number().int().min(1).max(5).default(3),
19371
- level: string().optional(),
19372
- attachments: array(AttachmentSchema).optional(),
19373
- clickUrl: string().optional(),
19374
- actions: array(NotificationActionSchema).optional(),
19375
- sound: string().optional(),
19376
- ttl: number().optional(),
19377
- tag: string().optional(),
19378
- deviceId: number().optional(),
19379
- eventId: string().optional(),
19380
- metadata: record(string(), unknown()).optional()
19859
+ privateBytes: number().optional(),
19860
+ /**
19861
+ * Shared file-backed resident bytes (Linux RssFile) — mmap'd framework/lib
19862
+ * code shared copy-on-write across runners. Undefined on macOS.
19863
+ */
19864
+ sharedBytes: number().optional()
19381
19865
  });
19382
- /** One declared native severity/priority level for a kind. */
19383
- var TargetKindLevelSchema = object({
19384
- id: string(),
19385
- label: string(),
19386
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
19387
- ordinal: number().int().min(1).max(5).nullable(),
19388
- flags: object({
19389
- critical: boolean().optional(),
19390
- silent: boolean().optional(),
19391
- noPush: boolean().optional()
19392
- }).optional(),
19393
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
19394
- requires: array(string()).optional(),
19395
- description: string().optional()
19866
+ var AddonInstanceSchema = object({
19867
+ addonId: string(),
19868
+ nodeId: string(),
19869
+ role: _enum(["hub", "worker"]),
19870
+ pid: number(),
19871
+ state: _enum([
19872
+ "starting",
19873
+ "running",
19874
+ "stopping",
19875
+ "stopped",
19876
+ "crashed"
19877
+ ]),
19878
+ uptimeSec: number()
19396
19879
  });
19397
- /** The full capability block consulted before dispatch. */
19398
- var TargetKindCapsSchema = object({
19399
- attachments: object({
19400
- mediaTypes: array(AttachmentMediaTypeSchema),
19401
- mode: _enum([
19402
- "url",
19403
- "bytes",
19404
- "both"
19405
- ]),
19406
- max: number().int().nonnegative(),
19407
- maxBytes: number().int().positive().optional()
19408
- }),
19409
- /** Max action buttons (0 = none). */
19410
- actions: number().int().nonnegative(),
19411
- levels: array(TargetKindLevelSchema),
19412
- format: array(NotificationFormatSchema),
19413
- clickUrl: boolean(),
19414
- sound: boolean(),
19415
- ttl: boolean(),
19416
- bodyMaxLen: number().int().positive()
19880
+ var NodeProcessSchema = object({
19881
+ pid: number(),
19882
+ ppid: number(),
19883
+ pgid: number(),
19884
+ classification: _enum([
19885
+ "root",
19886
+ "managed",
19887
+ "system",
19888
+ "ghost"
19889
+ ]),
19890
+ /** `$process` addon binding when `managed`, else null. */
19891
+ addonId: string().nullable(),
19892
+ /** Kernel-reported nodeId when the process is a known agent/worker. */
19893
+ nodeId: string().nullable(),
19894
+ /** Truncated command line. */
19895
+ command: string(),
19896
+ cpuPercent: number(),
19897
+ memoryRssBytes: number(),
19898
+ /** Wall-clock uptime (seconds). Parsed from `ps etime`. */
19899
+ uptimeSec: number(),
19900
+ /** True when ancestor walk reaches `ppid=1` (reparented to init/launchd). */
19901
+ orphaned: boolean()
19417
19902
  });
19418
- /**
19419
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
19420
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
19421
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`)
19422
- * the union is large and not meant for runtime validation here; the exported
19423
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
19424
- */
19425
- var ConfigSchemaPassthrough = unknown();
19426
- var TargetKindSchema = object({
19427
- kind: string(),
19428
- label: string(),
19429
- icon: string(),
19430
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
19431
- addonId: string(),
19432
- configSchema: ConfigSchemaPassthrough,
19433
- supportsDiscovery: boolean(),
19434
- caps: TargetKindCapsSchema
19903
+ var KillProcessInputSchema = object({
19904
+ pid: number(),
19905
+ /** Force = SIGKILL. Default is SIGTERM. */
19906
+ force: boolean().optional()
19435
19907
  });
19436
- /**
19437
- * A persisted target. `config` holds secrets; providers REDACT secret fields
19438
- * (return a presence marker only) when serving `listTargets` — never
19439
- * round-trip a stored secret to the UI.
19440
- */
19441
- var TargetSchema = object({
19442
- id: string(),
19443
- name: string(),
19444
- kind: string(),
19445
- addonId: string(),
19446
- enabled: boolean(),
19447
- config: record(string(), unknown())
19908
+ var KillProcessResultSchema = object({
19909
+ success: boolean(),
19910
+ reason: string().optional(),
19911
+ signal: _enum(["SIGTERM", "SIGKILL"]).optional()
19448
19912
  });
19449
- /** A discovery-surfaced candidate (config is partial + non-secret). */
19450
- var DiscoveredTargetSchema = object({
19451
- kind: string(),
19452
- suggestedName: string(),
19453
- config: record(string(), unknown())
19913
+ var DumpHeapSnapshotInputSchema = object({
19914
+ /** The addon whose runner should dump a heap snapshot. */
19915
+ addonId: string() });
19916
+ var DumpHeapSnapshotResultSchema = object({
19917
+ success: boolean(),
19918
+ /** Path of the written .heapsnapshot inside the runner's container/host. */
19919
+ path: string().optional(),
19920
+ /** Process pid that was signalled. */
19921
+ pid: number().optional(),
19922
+ reason: string().optional()
19454
19923
  });
19455
- /** The degrade engine's report — what was resolved / dropped / degraded. */
19456
- var RenderedAsSchema = object({
19457
- level: string(),
19458
- format: NotificationFormatSchema,
19459
- attachmentsSent: number().int().nonnegative(),
19460
- actionsSent: number().int().nonnegative(),
19461
- truncated: boolean(),
19462
- dropped: array(string())
19924
+ var SystemMetricsSchema = object({
19925
+ cpuPercent: number(),
19926
+ memoryPercent: number(),
19927
+ memoryUsedMB: number(),
19928
+ memoryTotalMB: number(),
19929
+ diskPercent: number().optional(),
19930
+ temperature: number().optional(),
19931
+ gpuPercent: number().optional(),
19932
+ gpuMemoryPercent: number().optional()
19463
19933
  });
19464
- var SendResultSchema = object({
19465
- success: boolean(),
19466
- error: string().optional(),
19467
- renderedAs: RenderedAsSchema.optional()
19934
+ method(_void(), SystemResourceSnapshotSchema), method(_void(), SystemResourceSnapshotSchema.nullable()), method(_void(), SystemMetricsSchema), method(object({ dirPath: string() }), DiskSpaceInfoSchema), method(_void(), MetricsGpuInfoSchema.nullable()), method(_void(), number().nullable()), method(object({ pids: array(number()) }), array(PidResourceStatsSchema)), method(_void(), array(AddonInstanceSchema).readonly()), method(object({ addonId: string() }), PidResourceStatsSchema.nullable()), method(_void(), array(NodeProcessSchema).readonly()), method(KillProcessInputSchema, KillProcessResultSchema, {
19935
+ kind: "mutation",
19936
+ auth: "admin"
19937
+ }), method(DumpHeapSnapshotInputSchema, DumpHeapSnapshotResultSchema, {
19938
+ kind: "mutation",
19939
+ auth: "admin"
19940
+ });
19941
+ method(object({
19942
+ sourceUrl: string(),
19943
+ metadata: ModelConvertMetadataSchema,
19944
+ targets: array(ConvertTargetSchema).min(1).readonly(),
19945
+ calibrationRef: string().optional(),
19946
+ sessionId: string().optional()
19947
+ }), ConvertResultSchema, {
19948
+ kind: "mutation",
19949
+ auth: "admin",
19950
+ timeoutMs: 6e5
19951
+ });
19952
+ method(object({
19953
+ nodeId: string(),
19954
+ modelId: string(),
19955
+ format: _enum(MODEL_FORMATS),
19956
+ entry: ModelCatalogEntrySchema
19957
+ }), object({
19958
+ ok: boolean(),
19959
+ /** sha256 of the staged tarball (empty for a hub-local no-op). */
19960
+ sha256: string(),
19961
+ bytes: number(),
19962
+ /** The target node's modelsDir the artifact landed in. */
19963
+ path: string()
19964
+ }), {
19965
+ kind: "mutation",
19966
+ auth: "admin"
19468
19967
  });
19469
- /** Same shape as SendResult — kept as a distinct name for the test panel. */
19470
- var TestResultSchema = SendResultSchema;
19471
- var notificationOutputCapability = {
19472
- name: "notification-output",
19473
- scope: "system",
19474
- mode: "collection",
19475
- methods: {
19476
- listTargetKinds: method(object({}), array(TargetKindSchema)),
19477
- listTargets: method(object({}), array(TargetSchema)),
19478
- discoverTargets: method(object({
19479
- kind: string(),
19480
- config: record(string(), unknown()).optional()
19481
- }), array(DiscoveredTargetSchema)),
19482
- send: method(object({
19483
- targetId: string(),
19484
- notification: NotificationSchema
19485
- }), SendResultSchema, { kind: "mutation" }),
19486
- testTarget: method(object({
19487
- targetId: string(),
19488
- sample: NotificationSchema.optional()
19489
- }), TestResultSchema, { kind: "mutation" }),
19490
- upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
19491
- deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
19492
- setTargetEnabled: method(object({
19493
- targetId: string(),
19494
- enabled: boolean()
19495
- }), _void(), { kind: "mutation" })
19496
- }
19497
- };
19498
19968
  /**
19499
- * notification-rulesthe Notification Center rule surface (P1 core).
19969
+ * `mqtt-broker`broker-registry cap.
19500
19970
  *
19501
- * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
19502
- * (operator decisions D-1/D-2/D-3 are binding):
19971
+ * NOT a pub/sub proxy. The cap exposes (a) a registry of configured
19972
+ * MQTT brokers (external + optionally an embedded `aedes`-backed one)
19973
+ * and (b) the connection details a consumer addon needs to spin up
19974
+ * its OWN `mqtt.js` client.
19503
19975
  *
19504
- * - D-2: rule EVALUATION lives in `addon-post-analysis` (the
19505
- * `notification-center` module), hooked on the durable persistence
19506
- * moments (object-event insert, TrackCloser.closeExpired) with a
19507
- * persisted outbox + retry never the lossy telemetry bus (D8).
19508
- * - D-3: urgency belongs to the RULE. `delivery: 'immediate'` fires on the
19509
- * FIRST persisted detection matching the conditions (per-track dedup,
19510
- * `maxPerTrack` fixed at 1 — see {@link NC_MAX_PER_TRACK_IMMEDIATE});
19511
- * `delivery: 'track-end'` evaluates the finalized track record at close.
19512
- * - DISPATCH stays behind `notification-output` (rules reference targets
19513
- * by id; per-backend params are a passthrough blob capped by the
19514
- * target kind's own caps/degrade engine).
19976
+ * Why: pub/sub routing over the system event-bus loses fidelity
19977
+ * (callback shape, QoS guarantees, will/retain semantics) and adds
19978
+ * refcount bookkeeping that addons would rather own themselves. The
19979
+ * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
19980
+ * features anyway give it the connection config, get out of the way.
19515
19981
  *
19516
- * P1 scope: admin-authored rules only (`createdBy` stamped from the
19517
- * server-injected caller identity the first `caller: 'required'`
19518
- * adopter). The P1 condition subset is: devices, classes(+exclude),
19519
- * minConfidence, admin zones (any/all + exclude), weekly schedule
19520
- * windows, and the optional label/identity/plate matchers. User rules,
19521
- * private zones, per-recipient fan-out and the wider condition table are
19522
- * P2+ (see spec §7).
19982
+ * Consumer flow:
19983
+ * const cfg = await ctx.api.mqttBroker.getBrokerConfig({ id })
19984
+ * const client = mqtt.connect(cfg.url, { username: cfg.username, … })
19985
+ * client.subscribe('zigbee2mqtt/+')
19523
19986
  *
19524
- * All schemas here are the single source of truth — `NcRule` etc. are
19525
- * `z.infer` exports; no duplicate interfaces (the advanced-notifier
19526
- * schema/interface drift is explicitly not repeated).
19987
+ * Collection mode: multiple brokers (e.g. one local mosquitto + one
19988
+ * cloud bridge). The "embedded" entry (when present) is just another
19989
+ * broker in the registry — its lifecycle is owned by the addon that
19990
+ * spawned it.
19527
19991
  */
19992
+ var BrokerKindSchema = _enum(["external", "embedded"]);
19528
19993
  /**
19529
- * D-3: the trigger/urgency of a rule — which persistence moment evaluates it.
19530
- * The value maps 1:1 onto the evaluated record kind:
19531
- * - `immediate` ↔ object-event persist (lowest-latency detection burst)
19532
- * - `track-end` ↔ TrackCloser.closeExpired (finalized track record)
19533
- * - `device-event` ↔ SensorEventStore insert (doorbell press / sensor state
19534
- * change of a LINKED device, one row per linked camera)
19535
- * - `package-event` ↔ PackageDropDetector object-event insert (a `package`
19536
- * delivery / pick-up)
19994
+ * Broker live-probe status.
19537
19995
  *
19538
- * `immediate`/`track-end` carry the D-3 urgency semantics; `device-event`/
19539
- * `package-event` are pure trigger kinds (no urgency dimension). Extending
19540
- * this one field keeps the schema additive a rule still declares exactly
19541
- * one trigger.
19542
- */
19543
- var NcDeliverySchema = _enum([
19544
- "immediate",
19545
- "track-end",
19546
- "device-event",
19547
- "package-event"
19548
- ]);
19549
- /** Weekly schedule — OR of windows; absence on the rule = always active. */
19550
- var NcScheduleSchema = object({
19551
- windows: array(object({
19552
- /** Days of week the window STARTS on (0 = Sunday … 6 = Saturday). */
19553
- days: array(number().int().min(0).max(6)).min(1),
19554
- startMinute: number().int().min(0).max(1439),
19555
- endMinute: number().int().min(0).max(1439)
19556
- })).min(1),
19557
- /** IANA timezone; default = hub host timezone. */
19558
- timezone: string().optional(),
19559
- /** Active OUTSIDE the windows (e.g. "only outside business hours"). */
19560
- invert: boolean().optional()
19561
- });
19562
- /** Fuzzy plate matcher — OCR noise makes exact match useless (spec row 12/13). */
19563
- var NcPlateMatcherSchema = object({
19564
- values: array(string().min(1)).min(1),
19565
- /** Max Levenshtein distance after normalization (uppercase alphanumeric). */
19566
- maxDistance: number().int().min(0).max(3).default(1)
19567
- });
19568
- /**
19569
- * Occupancy condition (DEVICE-EVENT trigger). Fires on a ZoneAnalytics
19570
- * occupancy edge for a device — optionally narrowed to a single admin
19571
- * `zoneId` and/or object `className`. `op` selects the edge/threshold:
19572
- * - `became-occupied` (default) — count crossed 0 → ≥ `count`
19573
- * - `became-free` — count crossed ≥ `count` → below it
19574
- * - `>=` / `<=` — count is at/over or at/under `count`
19575
- * `sustainSeconds` requires the condition hold continuously that long
19576
- * before firing (debounces flicker; 0 = fire on the first matching edge).
19577
- * Fail-closed: no ZoneAnalytics snapshot / missing zone / null snapshot ⇒
19578
- * the condition never matches. Confirmed edge-state survives addon restarts
19579
- * (declared SQLite collection, reseeded on boot).
19996
+ * - `connected` last probe completed a clean CONNACK
19997
+ * - `disconnected` no probe has run yet (cold cache)
19998
+ * - `auth-failed` CONNACK refused with auth error (RC 4 / 5)
19999
+ * - `unreachable` — TCP connect timed out / refused
20000
+ * - `tls-error` — TLS handshake failed (cert / SNI / cipher)
19580
20001
  */
19581
- var NcOccupancyConditionSchema = object({
19582
- /** Admin zone id to scope the count to; absent = whole-frame occupancy. */
19583
- zoneId: string().optional(),
19584
- /** Object class to count; absent = any class. */
19585
- className: string().optional(),
19586
- op: _enum([
19587
- "became-occupied",
19588
- "became-free",
19589
- ">=",
19590
- "<="
19591
- ]).default("became-occupied"),
19592
- count: number().int().min(0).default(1),
19593
- sustainSeconds: number().int().min(0).max(3600).default(15)
19594
- });
19595
- /** Admin-zone membership condition (zone IDs as stamped by the ZoneEngine). */
19596
- var NcZoneConditionSchema = object({
19597
- ids: array(string().min(1)).min(1),
19598
- /** Quantifier over `ids` at least one / every one visited. */
19599
- match: _enum(["any", "all"]).default("any")
20002
+ var BrokerStatusSchema$1 = _enum([
20003
+ "connected",
20004
+ "disconnected",
20005
+ "auth-failed",
20006
+ "unreachable",
20007
+ "tls-error"
20008
+ ]);
20009
+ var BrokerInfoSchema = object({
20010
+ id: string(),
20011
+ name: string(),
20012
+ url: string(),
20013
+ kind: BrokerKindSchema,
20014
+ status: BrokerStatusSchema$1,
20015
+ latencyMs: number().nullable(),
20016
+ error: string().optional(),
20017
+ /** Embedded brokers only: number of MQTT clients currently connected. */
20018
+ connectedClients: number().int().nonnegative().optional(),
20019
+ /** Epoch ms of the last live probe (external) or aedes snapshot (embedded). */
20020
+ lastCheckedAt: number().optional()
19600
20021
  });
19601
20022
  /**
19602
- * The P1 condition set a flat AND of groups; absent group = pass;
19603
- * membership lists are OR within the list (spec §2.3).
20023
+ * Connection details what a consumer needs to call
20024
+ * `mqtt.connect(url, options)`. We split URL + credentials so the
20025
+ * consumer can pass them as `mqtt.connect(url, { username, password })`
20026
+ * instead of stuffing creds into the URL (which leaks them into logs).
19604
20027
  */
19605
- var NcConditionsSchema = object({
19606
- /** Device scope — absent = all devices. */
19607
- devices: array(number()).optional(),
19608
- /** Detector class names (any overlap with the record's class set). */
19609
- classes: array(string().min(1)).optional(),
19610
- /** Veto classes — any overlap fails the rule. */
19611
- classesExclude: array(string().min(1)).optional(),
19612
- /** Minimum detection confidence 0–1 (fails when the record has none). */
19613
- minConfidence: number().min(0).max(1).optional(),
19614
- /** Admin zone membership over event `zones` / track `zonesVisited`. */
19615
- zones: NcZoneConditionSchema.optional(),
19616
- /** Veto zones — any hit fails the rule. */
19617
- zonesExclude: array(string().min(1)).optional(),
19618
- /**
19619
- * Exact (case-insensitive) match on the record's collapsed `label`
19620
- * (identity name / plate text / subclass).
19621
- */
19622
- labelEquals: array(string().min(1)).optional(),
19623
- /**
19624
- * Identity matcher. P1 boundary: matched against the record's collapsed
19625
- * `label` (the identity display name propagated by the face pipeline) —
19626
- * identity-ID matching rides in P2 when identity ids reach the record.
19627
- */
19628
- identities: array(string().min(1)).optional(),
19629
- /** Fuzzy plate matcher against the record's `label` (plate text). */
19630
- plates: NcPlateMatcherSchema.optional(),
19631
- /**
19632
- * Identity EXCLUDE — mirror of {@link identities} with `notIn` semantics.
19633
- * Same P1 boundary: matched against the record's collapsed `label` (the
19634
- * identity display name). A record with NO label passes (nothing to
19635
- * exclude), unlike the include variant which fails on an absent label.
19636
- */
19637
- identitiesExclude: array(string().min(1)).optional(),
19638
- /**
19639
- * Minimum server-computed key-event importance in [0,1] (`Track.importance`).
19640
- * TRACK-END only: importance is scored at track close, so it does not exist
19641
- * at immediate / object-event evaluation time (see catalog `appliesTo`). At
19642
- * close the value is threaded via the close-time info (the `Track` clone is
19643
- * captured before the DB row is updated, so it would otherwise read stale).
19644
- * Fails when the record carries no importance (never guess quality — the
19645
- * `minConfidence` precedent). MVP cut: a single scalar threshold.
19646
- */
19647
- minImportance: number().min(0).max(1).optional(),
19648
- /**
19649
- * Minimum track dwell in SECONDS — `(lastSeen − firstSeen) / 1000`.
19650
- * TRACK-END only: an `immediate` / object-event subject has no closed
19651
- * lifespan, so a dwell condition never matches immediate delivery
19652
- * (documented choice — the object-event record carries no `firstSeen`,
19653
- * so dwell cannot be computed from what the subject actually carries).
19654
- */
19655
- minDwellSeconds: number().min(0).optional(),
19656
- /**
19657
- * Detection provenance filter. `any` (default / absent) matches every
19658
- * source; otherwise the subject's source must equal it. Legacy records
19659
- * with no stamped source are treated as `pipeline`. The union spans both
19660
- * record kinds — object events carry `pipeline` | `onboard`, synthetic
19661
- * tracks carry `sensor`.
19662
- */
19663
- source: _enum([
19664
- "pipeline",
19665
- "onboard",
19666
- "sensor",
19667
- "any"
19668
- ]).optional(),
19669
- /**
19670
- * Minimum identity / plate MATCH confidence in [0,1] — DISTINCT from the
19671
- * detector `minConfidence` (that gates the object-detection score; this
19672
- * gates the recognition/OCR match score). Fails when the subject carries
19673
- * no label-match confidence (never guess). TRACK-END only: the confidence
19674
- * lives on the recognition result and reaches the subject at track close.
19675
- *
19676
- * What it measures precisely (plumbed at track close — the closer threads
19677
- * the value into `NcTrackClosedInfo.labelConfidence`, the same seam as
19678
- * `importance`): the BEST recognition match confidence observed for the
19679
- * label the track carries at close — for a face, the peak cosine similarity
19680
- * of the ASSIGNED identity (`FaceMatch.score`, reset on an identity switch);
19681
- * for a plate, the peak OCR read score of the best-held plate
19682
- * (`plateText.confidence`). When BOTH a face and a plate were recognized on
19683
- * one track the higher of the two is used. A track that ended with no
19684
- * confident identity/plate match carries no value, so the condition fails
19685
- * closed for it (an un-recognized subject).
19686
- */
19687
- minLabelConfidence: number().min(0).max(1).optional(),
19688
- /**
19689
- * DEVICE-EVENT only. Raw device event-type tokens (`EventFire.eventType`,
19690
- * e.g. a doorbell `press` / `press_long`) — matched case-insensitively
19691
- * against the token carried on the device-event subject (extracted from the
19692
- * event-emitter runtime slice's `lastEvent.eventType`). Fails when the
19693
- * subject carries no token. Doorbell-pulse / passive-sensor kinds emit no
19694
- * eventType, so gate those with {@link sensorKinds} instead.
19695
- */
19696
- eventTypeTokens: array(string().min(1)).optional(),
19697
- /**
19698
- * DEVICE-EVENT only. Sensor/control taxonomy kinds (e.g. `doorbell`,
19699
- * `contact`, `button`, `device-event`) — matched against the persisted
19700
- * `SensorEvent.kind` (see `sensor-event-kinds.ts`). Membership is OR.
19701
- */
19702
- sensorKinds: array(string().min(1)).optional(),
19703
- /**
19704
- * PACKAGE-EVENT only. Which package phase fires the rule — `delivered`
19705
- * (a parked parcel appeared), `picked-up` (it departed), or `both`. Fails
19706
- * when the subject's phase does not match (a subject always carries a phase
19707
- * on the package-event trigger).
19708
- */
19709
- packagePhase: _enum([
19710
- "delivered",
19711
- "picked-up",
19712
- "both"
19713
- ]).optional(),
19714
- /**
19715
- * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
19716
- * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
19717
- * listed polygon (ZoneEngine membership semantics). Evaluated only when
19718
- * the subject carries a bbox; absent bbox ⇒ the condition FAILS.
19719
- */
19720
- customZones: array(MaskPolygonShapeSchema).optional(),
20028
+ var BrokerConnectionDetailsSchema = object({
20029
+ url: string(),
20030
+ username: string().optional(),
20031
+ password: string().optional(),
19721
20032
  /**
19722
- * DEVICE-EVENT only. ZoneAnalytics occupancy edge fires when a device's
19723
- * (optionally zone/class-scoped) occupancy count crosses the configured
19724
- * threshold and holds for `sustainSeconds`. Fail-closed on missing
19725
- * substrate (no snapshot / missing zone). See {@link NcOccupancyCondition}.
20033
+ * Suggested prefix for `clientId`. Each consumer should suffix this
20034
+ * with its own discriminator (addon id, instance id) so reconnects
20035
+ * don't kick each other off (MQTT spec: clientId must be unique per
20036
+ * broker).
19726
20037
  */
19727
- occupancy: NcOccupancyConditionSchema.optional()
20038
+ clientIdPrefix: string().optional()
19728
20039
  });
19729
- /** One delivery target: a `notification-output` Target ref + passthrough params. */
19730
- var NcRuleTargetSchema = object({
19731
- /** `notification-output` Target id. */
19732
- targetId: string().min(1),
19733
- /**
19734
- * Per-backend passthrough. Recognized keys are mapped onto the canonical
19735
- * Notification (`priority`, `level`, `sound`, `clickUrl`, `ttl`); the
19736
- * degrade engine drops what the backend can't render.
19737
- */
19738
- params: record(string(), unknown()).optional()
20040
+ var AddBrokerInputSchema = object({
20041
+ name: string().min(1),
20042
+ url: string().regex(/^(mqtt|mqtts|ws|wss):\/\//, "URL must start with mqtt(s):// or ws(s)://"),
20043
+ username: string().optional(),
20044
+ password: string().optional(),
20045
+ clientIdPrefix: string().optional()
20046
+ });
20047
+ var AddBrokerResultSchema = object({ id: string() });
20048
+ var IdInputSchema = object({ id: string() });
20049
+ var TestResultSchema$1 = discriminatedUnion("ok", [object({
20050
+ ok: literal(true),
20051
+ latencyMs: number()
20052
+ }), object({
20053
+ ok: literal(false),
20054
+ error: string()
20055
+ })]);
20056
+ var StartEmbeddedInputSchema = object({
20057
+ port: number().int().min(1).max(65535).default(1883),
20058
+ /** Allow anonymous connect (no username/password). Default: false. */
20059
+ allowAnonymous: boolean().default(false),
20060
+ /** Optional shared username/password for clients. */
20061
+ username: string().optional(),
20062
+ password: string().optional()
20063
+ });
20064
+ var StartEmbeddedResultSchema = object({
20065
+ id: string(),
20066
+ url: string()
20067
+ });
20068
+ var StatusSchema = object({
20069
+ brokerCount: number(),
20070
+ embeddedRunning: boolean()
20071
+ });
20072
+ method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema$1, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
20073
+ var NetworkEndpointSchema = object({
20074
+ url: string(),
20075
+ hostname: string(),
20076
+ port: number(),
20077
+ protocol: _enum(["http", "https"])
20078
+ });
20079
+ var NetworkAccessStatusSchema = object({
20080
+ connected: boolean(),
20081
+ endpoint: NetworkEndpointSchema.nullable(),
20082
+ error: string().optional()
19739
20083
  });
19740
20084
  /**
19741
- * Media attachment policy (P1 still-image subset).
19742
- * - `best` the best AVAILABLE subject image at dispatch time (D-3).
19743
- * - `best-matching` the media that explains WHY the rule fired: a rule
19744
- * matched on identities attaches the subject's `faceCrop`, one matched on
19745
- * plates attaches the `plateCrop`; a rule with no identity/plate condition
19746
- * (or when the specific crop is missing) degrades to `best`, then
19747
- * `keyFrame`, then no attachment — never delaying the send. The matched
19748
- * condition summary is frozen on the outbox row at enqueue (like the rule
19749
- * name), so the choice never drifts from the record that fired it.
19750
- * - `keyFrame` — the clean scene frame (no subject box).
19751
- * - `none` — no attachment.
20085
+ * Optional, richer endpoint shape returned by providers that expose
20086
+ * MORE than one ingress concurrently (Tailscale Ingress with mixed
20087
+ * serve+funnel rules, future ngrok multi-tunnel, …). Each entry carries
20088
+ * the originating provider config (mode + sourcePort) so the
20089
+ * orchestrator UI can label rows distinctly. Providers that expose only
20090
+ * one endpoint just omit `listEndpoints` from their provider impl.
19752
20091
  */
19753
- var NcMediaPolicySchema = object({ attach: _enum([
19754
- "best",
19755
- "best-matching",
19756
- "keyFrame",
19757
- "none"
19758
- ]).default("best") });
19759
- /** Throttle — cooldown survives restarts (rebuilt from the outbox on boot). */
19760
- var NcThrottleSchema = object({
19761
- cooldownSec: number().int().min(0).max(86400).default(60),
19762
- /** `rule` = one shared cooldown; `rule-device` = per-camera cooldown. */
19763
- scope: _enum(["rule", "rule-device"]).default("rule-device")
19764
- });
19765
- /** Client-supplied rule fields (server stamps id/createdBy/createdAt/updatedAt). */
19766
- var NcRuleInputSchema = object({
19767
- name: string().min(1).max(200),
19768
- enabled: boolean().default(true),
19769
- delivery: NcDeliverySchema,
19770
- conditions: NcConditionsSchema.default({}),
19771
- schedule: NcScheduleSchema.optional(),
19772
- targets: array(NcRuleTargetSchema).min(1),
19773
- media: NcMediaPolicySchema.default({ attach: "best" }),
19774
- throttle: NcThrottleSchema.default({
19775
- cooldownSec: 60,
19776
- scope: "rule-device"
19777
- }),
19778
- /** `{{var}}` templating over camera/class/label/zones/confidence/time. */
19779
- template: object({
19780
- title: string().max(500).optional(),
19781
- body: string().max(2e3).optional()
19782
- }).optional(),
19783
- /** Canonical notification priority ordinal (1..5); per-target overridable. */
19784
- priority: number().int().min(1).max(5).default(3),
19785
- /**
19786
- * Ownership/visibility key. Absent = admin/global rule (unchanged legacy
19787
- * behaviour, visible to all, read-only in the viewer). Present = personal
19788
- * rule owned by this userId. Server-stamped; never trusted from a client.
20092
+ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
20093
+ /**
20094
+ * Stable id within the provider — typically `<mode>-<sourcePort>` so
20095
+ * the orchestrator can dedupe across `listEndpoints` polls.
19789
20096
  */
19790
- ownerUserId: string().optional()
20097
+ id: string(),
20098
+ /** Operator-facing label (mirrors `MeshEndpoint.label`). */
20099
+ label: string(),
20100
+ /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
20101
+ mode: string().optional(),
20102
+ /** Originating local port the ingress fronts (informational). */
20103
+ sourcePort: number().optional()
19791
20104
  });
20105
+ method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
19792
20106
  /**
19793
- * Partial patch for `updateRule` any subset of the input fields, plus the
19794
- * persisted-only {@link NcRuleSchema} `disabledTargetIds` set. The latter is
19795
- * NOT a client-authored input field (it lives on the persisted rule, not the
19796
- * input), so it is added here explicitly to let the store's per-target opt-out
19797
- * toggle round-trip through the shared `update` path. Viewer opt-out mutations
19798
- * still flow through `nc.setRuleTargetEnabled` (owner-checked), never a raw
19799
- * `updateRule` patch.
20107
+ * notification-outputcanonical, capability-gated notification delivery.
20108
+ *
20109
+ * Apprise-derived model (see
20110
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
20111
+ * callers emit ONE canonical `Notification`; each provider declares a
20112
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
20113
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
20114
+ * message to what the kind supports — callers never special-case a service.
20115
+ *
20116
+ * DESIGN DECISIONS (locked):
20117
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
20118
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
20119
+ * cap. Rationale: the admin UI needs one uniform surface across the
20120
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
20121
+ * alternative would fork the UI per addon and cannot host the
20122
+ * discovery→adopt flow.
20123
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
20124
+ * the generated cap-mount auto-`concatCollection`-fans them across every
20125
+ * registered provider (notifiers addon + HA addon) so one catalog is
20126
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
20127
+ * `addonId` the generated collection router extracts from the call input.
20128
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
20129
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
20130
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
20131
+ * base64 fallback needed.
20132
+ *
20133
+ * TODO (deferred, closed-set change — separate decision): add
20134
+ * `providerKind: 'notify'` so notification providers surface on the unified
20135
+ * admin "Integrations" page.
19800
20136
  */
19801
- var NcRulePatchSchema = NcRuleInputSchema.partial().extend({ disabledTargetIds: array(string()).optional() });
19802
- /** A persisted rule. */
19803
- var NcRuleSchema = NcRuleInputSchema.extend({
20137
+ /**
20138
+ * Zentik-derived typed-media enum — the superset across every kind. Each
20139
+ * adapter picks what it supports and the degrade engine filters the rest.
20140
+ */
20141
+ var AttachmentMediaTypeSchema = _enum([
20142
+ "image",
20143
+ "video",
20144
+ "gif",
20145
+ "audio",
20146
+ "icon"
20147
+ ]);
20148
+ /**
20149
+ * A single attachment. Exactly one of `url` (remote source, most adapters
20150
+ * prefer this) or `bytes` (inline source; required for Pushover-style
20151
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
20152
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
20153
+ */
20154
+ var AttachmentSchema = object({
20155
+ mediaType: AttachmentMediaTypeSchema,
20156
+ url: string().optional(),
20157
+ bytes: _instanceof(Uint8Array).optional(),
20158
+ mime: string().optional(),
20159
+ name: string().optional()
20160
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
20161
+ var NotificationFormatSchema = _enum([
20162
+ "text",
20163
+ "markdown",
20164
+ "html"
20165
+ ]);
20166
+ /** A single tap-through action button. */
20167
+ var NotificationActionSchema = object({
19804
20168
  id: string(),
19805
- /** userId of the admin who created the rule (server-stamped caller). */
19806
- createdBy: string(),
19807
- createdAt: number(),
19808
- updatedAt: number(),
19809
- /**
19810
- * Per-target opt-out set. A targetId here is suppressed for THIS rule at
19811
- * send time. Only a target's OWNER may add/remove its id (server-checked
19812
- * in `nc.setRuleTargetEnabled`). Defaults to empty.
19813
- */
19814
- disabledTargetIds: array(string()).default([])
20169
+ label: string(),
20170
+ url: string().optional()
19815
20171
  });
19816
- var NcTestResultSchema = object({
19817
- recordId: string(),
19818
- recordKind: _enum([
19819
- "object-event",
19820
- "track",
19821
- "device-event",
19822
- "package-event"
19823
- ]),
19824
- deviceId: number(),
19825
- timestamp: number(),
19826
- wouldFire: boolean(),
19827
- /** Condition id that failed (first failing group), when `wouldFire` is false. */
19828
- failedCondition: string().optional(),
19829
- className: string().optional(),
19830
- label: string().optional()
20172
+ /**
20173
+ * The canonical notification. `body` is the only hard field (Apprise model).
20174
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
20175
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
20176
+ * the adapter maps this ordinal onto its native level. `level?` is an
20177
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
20178
+ * `priority` for that one target.
20179
+ */
20180
+ var NotificationSchema = object({
20181
+ body: string(),
20182
+ title: string().optional(),
20183
+ format: NotificationFormatSchema.default("text"),
20184
+ priority: number().int().min(1).max(5).default(3),
20185
+ level: string().optional(),
20186
+ attachments: array(AttachmentSchema).optional(),
20187
+ clickUrl: string().optional(),
20188
+ actions: array(NotificationActionSchema).optional(),
20189
+ sound: string().optional(),
20190
+ ttl: number().optional(),
20191
+ tag: string().optional(),
20192
+ deviceId: number().optional(),
20193
+ eventId: string().optional(),
20194
+ metadata: record(string(), unknown()).optional()
19831
20195
  });
19832
- var NcConditionDescriptorSchema = object({
19833
- /** Field id inside `NcConditions` (or `'schedule'` for the rule-level group). */
20196
+ /** One declared native severity/priority level for a kind. */
20197
+ var TargetKindLevelSchema = object({
19834
20198
  id: string(),
19835
- group: _enum([
19836
- "scope",
19837
- "class",
19838
- "zones",
19839
- "quality",
19840
- "label",
19841
- "schedule",
19842
- "device",
19843
- "package",
19844
- "occupancy"
19845
- ]),
19846
20199
  label: string(),
19847
- /** Editor widget the UI renders never hardcode per-condition forms. */
19848
- valueType: _enum([
19849
- "deviceIdList",
19850
- "stringList",
19851
- "number01",
19852
- "number",
19853
- "sourceSelect",
19854
- "zoneSelection",
19855
- "zoneIdList",
19856
- "schedule",
19857
- "plateMatcher",
19858
- "packagePhase",
19859
- "polygonDraw",
19860
- "occupancy"
19861
- ]),
19862
- operator: _enum([
19863
- "in",
19864
- "notIn",
19865
- "anyOf",
19866
- "allOf",
19867
- "gte",
19868
- "fuzzyIn",
19869
- "withinSchedule"
19870
- ]),
19871
- /** Which delivery kinds the condition applies to. */
19872
- appliesTo: array(NcDeliverySchema),
19873
- phase: string(),
20200
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
20201
+ ordinal: number().int().min(1).max(5).nullable(),
20202
+ flags: object({
20203
+ critical: boolean().optional(),
20204
+ silent: boolean().optional(),
20205
+ noPush: boolean().optional()
20206
+ }).optional(),
20207
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
20208
+ requires: array(string()).optional(),
19874
20209
  description: string().optional()
19875
20210
  });
20211
+ /** The full capability block consulted before dispatch. */
20212
+ var TargetKindCapsSchema = object({
20213
+ attachments: object({
20214
+ mediaTypes: array(AttachmentMediaTypeSchema),
20215
+ mode: _enum([
20216
+ "url",
20217
+ "bytes",
20218
+ "both"
20219
+ ]),
20220
+ max: number().int().nonnegative(),
20221
+ maxBytes: number().int().positive().optional()
20222
+ }),
20223
+ /** Max action buttons (0 = none). */
20224
+ actions: number().int().nonnegative(),
20225
+ levels: array(TargetKindLevelSchema),
20226
+ format: array(NotificationFormatSchema),
20227
+ clickUrl: boolean(),
20228
+ sound: boolean(),
20229
+ ttl: boolean(),
20230
+ bodyMaxLen: number().int().positive()
20231
+ });
19876
20232
  /**
19877
- * The delivery lifecycle status of a history row a straight read of the
19878
- * durable outbox row's own status (single source of truth):
19879
- * - `pending` — enqueued, in-flight or retrying with backoff
19880
- * - `sent` — delivered (terminal)
19881
- * - `dead` dead-lettered after exhausting retries / a permanent
19882
- * backend rejection / a deleted target (terminal; carries
19883
- * the failure `error`)
19884
- *
19885
- * P1 has no `suppressed-quiet-hours` / `snoozed` states — those ride the P2
19886
- * user dimension (quiet hours / snooze) and are additive when they land.
20233
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
20234
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
20235
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`)
20236
+ * the union is large and not meant for runtime validation here; the exported
20237
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
19887
20238
  */
19888
- var NcHistoryStatusSchema = _enum([
19889
- "pending",
19890
- "sent",
19891
- "dead"
19892
- ]);
19893
- /** The evaluated record kind a history row descends from (one per trigger). */
19894
- var NcHistoryRecordKindSchema = _enum([
19895
- "object-event",
19896
- "track-end",
19897
- "device-event",
19898
- "package-event"
19899
- ]);
19900
- /** Subject summary frozen on the row at fire time (survives rule/record edits). */
19901
- var NcHistorySubjectSchema = object({
19902
- className: string(),
19903
- label: string().optional(),
19904
- confidence: number().optional(),
19905
- zones: array(string()),
19906
- timestamp: number()
20239
+ var ConfigSchemaPassthrough = unknown();
20240
+ var TargetKindSchema = object({
20241
+ kind: string(),
20242
+ label: string(),
20243
+ icon: string(),
20244
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
20245
+ addonId: string(),
20246
+ /**
20247
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
20248
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
20249
+ * when the addon bundles no icon for that kind — the client then falls back
20250
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
20251
+ *
20252
+ * Root-relative on purpose: it resolves against whatever origin serves a web
20253
+ * client, and a native client joins it onto its own hub base.
20254
+ *
20255
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
20256
+ * field that survived only because the runtime cap-router forwards provider
20257
+ * output verbatim — so every consumer had to re-declare it by hand to stop
20258
+ * its own Zod parse from stripping it, and the whole arrangement would have
20259
+ * broken silently the moment output validation was tightened anywhere.
20260
+ */
20261
+ iconUrl: string().optional(),
20262
+ /**
20263
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
20264
+ *
20265
+ * The server knows this and therefore says it, because the client cannot
20266
+ * safely guess: a React-Native client renders SVG and raster through two
20267
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
20268
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
20269
+ * placeholder glyph for every vector icon while the web build looked fine.
20270
+ *
20271
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
20272
+ * not been updated — a client that cannot determine the type should prefer
20273
+ * its raster path, which is the safe default for an unknown image.
20274
+ */
20275
+ iconMediaType: string().optional(),
20276
+ configSchema: ConfigSchemaPassthrough,
20277
+ supportsDiscovery: boolean(),
20278
+ caps: TargetKindCapsSchema
19907
20279
  });
19908
20280
  /**
19909
- * One delivery-history row. This is a read-only VIEW over the durable
19910
- * outbox row (single source of truth the same row the drain loop drives;
19911
- * NO second write path, so history can never drift from delivery state).
19912
- * The §3.2 fields map directly: `ruleId`/`targetId`/`deviceId` are columns,
19913
- * `eventRef` is `recordKind`+`recordId`, `timestamps` are `createdAt`
19914
- * (fire) / `updatedAt` (last transition), `status` + `error` are the
19915
- * lifecycle. `ruleName` + `subject` are the intent snapshot frozen at
19916
- * enqueue. `userId?` (per-recipient history) is P2 — no user dimension in
19917
- * P1 (admin scope only).
20281
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
20282
+ * (return a presence marker only) when serving `listTargets` never
20283
+ * round-trip a stored secret to the UI.
19918
20284
  */
19919
- var NcHistoryEntrySchema = object({
19920
- /** Outbox row id — the stable dedup id `ruleId:dedupRef:targetId`. */
20285
+ var TargetSchema = object({
19921
20286
  id: string(),
19922
- ruleId: string(),
19923
- /** Rule name frozen at fire time (outlives a later rename / delete). */
19924
- ruleName: string(),
19925
- /** The rule urgency/trigger that produced this delivery. */
19926
- delivery: NcDeliverySchema,
19927
- targetId: string(),
19928
- deviceId: number(),
19929
- recordKind: NcHistoryRecordKindSchema,
19930
- /** Event / track ref of the evaluated record (§3.2 `eventRef`). */
19931
- recordId: string(),
19932
- /** Present for track-scoped deliveries (object-event / track-end). */
19933
- trackId: string().optional(),
19934
- status: NcHistoryStatusSchema,
19935
- /** Delivery attempts made so far. */
19936
- attempts: number().int(),
19937
- /** Fire time (outbox enqueue). */
19938
- createdAt: number(),
19939
- /** Last transition time (terminal for sent / dead). */
19940
- updatedAt: number(),
19941
- /** Failure detail — present on a `dead` row. */
19942
- error: string().optional(),
19943
- subject: NcHistorySubjectSchema
20287
+ name: string(),
20288
+ kind: string(),
20289
+ addonId: string(),
20290
+ enabled: boolean(),
20291
+ config: record(string(), unknown())
19944
20292
  });
19945
- /**
19946
- * Query filter for `getHistory` (spec §4.2). Every field is a narrowing
19947
- * AND; absent = unbounded on that axis. `since`/`until` bound the fire time
19948
- * (`createdAt`, epoch ms, inclusive). `limit` is clamped to
19949
- * {@link NC_HISTORY_LIMIT_MAX}. `userId` (per-recipient filtering) is P2.
19950
- */
19951
- var NcHistoryFilterSchema = object({
19952
- ruleId: string().optional(),
19953
- deviceId: number().optional(),
19954
- status: NcHistoryStatusSchema.optional(),
19955
- since: number().optional(),
19956
- until: number().optional(),
19957
- limit: number().int().min(1).max(500).default(100)
20293
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
20294
+ var DiscoveredTargetSchema = object({
20295
+ kind: string(),
20296
+ suggestedName: string(),
20297
+ config: record(string(), unknown())
19958
20298
  });
19959
- 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 }), {
19960
- kind: "mutation",
19961
- auth: "admin",
19962
- caller: "required"
19963
- }), method(object({
19964
- ruleId: string(),
19965
- patch: NcRulePatchSchema
19966
- }), object({ rule: NcRuleSchema }), {
19967
- kind: "mutation",
19968
- auth: "admin",
19969
- caller: "required"
19970
- }), method(object({ ruleId: string() }), object({ success: literal(true) }), {
19971
- kind: "mutation",
19972
- auth: "admin"
19973
- }), method(object({
19974
- ruleId: string(),
19975
- enabled: boolean()
19976
- }), object({ success: literal(true) }), {
19977
- kind: "mutation",
19978
- auth: "admin"
19979
- }), method(object({
19980
- rule: NcRuleInputSchema,
19981
- lookbackMinutes: number().int().min(1).max(1440).default(60)
19982
- }), object({ results: array(NcTestResultSchema) }), {
19983
- kind: "mutation",
19984
- auth: "admin"
19985
- }), method(object({}), object({ catalog: array(NcConditionDescriptorSchema) })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
20299
+ /** The degrade engine's report what was resolved / dropped / degraded. */
20300
+ var RenderedAsSchema = object({
20301
+ level: string(),
20302
+ format: NotificationFormatSchema,
20303
+ attachmentsSent: number().int().nonnegative(),
20304
+ actionsSent: number().int().nonnegative(),
20305
+ truncated: boolean(),
20306
+ dropped: array(string())
20307
+ });
20308
+ var SendResultSchema = object({
20309
+ success: boolean(),
20310
+ error: string().optional(),
20311
+ renderedAs: RenderedAsSchema.optional()
20312
+ });
20313
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
20314
+ var TestResultSchema = SendResultSchema;
20315
+ var notificationOutputCapability = {
20316
+ name: "notification-output",
20317
+ scope: "system",
20318
+ mode: "collection",
20319
+ methods: {
20320
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
20321
+ listTargets: method(object({}), array(TargetSchema)),
20322
+ discoverTargets: method(object({
20323
+ kind: string(),
20324
+ config: record(string(), unknown()).optional()
20325
+ }), array(DiscoveredTargetSchema)),
20326
+ send: method(object({
20327
+ targetId: string(),
20328
+ notification: NotificationSchema
20329
+ }), SendResultSchema, { kind: "mutation" }),
20330
+ testTarget: method(object({
20331
+ targetId: string(),
20332
+ sample: NotificationSchema.optional()
20333
+ }), TestResultSchema, { kind: "mutation" }),
20334
+ upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
20335
+ deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
20336
+ setTargetEnabled: method(object({
20337
+ targetId: string(),
20338
+ enabled: boolean()
20339
+ }), _void(), { kind: "mutation" })
20340
+ }
20341
+ };
19986
20342
  /**
19987
20343
  * Zod schemas for persisted record types.
19988
20344
  *
@@ -20662,6 +21018,15 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20662
21018
  }), method(object({ deviceId: number() }), EventPruneCountsSchema, {
20663
21019
  kind: "mutation",
20664
21020
  auth: "admin"
21021
+ }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
21022
+ kind: "mutation",
21023
+ auth: "admin"
21024
+ }), method(object({}), array(RelocateJobSchema).readonly(), {
21025
+ kind: "query",
21026
+ auth: "admin"
21027
+ }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
21028
+ kind: "mutation",
21029
+ auth: "admin"
20665
21030
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
20666
21031
  kind: "query",
20667
21032
  auth: "admin"
@@ -23230,6 +23595,17 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
23230
23595
  */
23231
23596
  priority: number()
23232
23597
  })).readonly() });
23598
+ /**
23599
+ * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
23600
+ * AUTO (resolved from the candidate ranking at send time); `resolved` reports
23601
+ * what AUTO currently picks, so the UI can show the effective value either way.
23602
+ */
23603
+ var NotificationEndpointSchema = object({
23604
+ /** The operator's explicit choice, or null for AUTO. */
23605
+ baseUrl: string().nullable(),
23606
+ /** What the ranking currently resolves to (null when nothing is reachable). */
23607
+ resolved: string().nullable()
23608
+ });
23233
23609
  var AllowedAddressesSchema = object({
23234
23610
  /**
23235
23611
  * Allowlist of interface addresses operators have explicitly opted
@@ -23252,7 +23628,7 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
23252
23628
  * to avoid mixed-content blocks in the browser. The public
23253
23629
  * tunnel always emits `https://` regardless. */
23254
23630
  scheme: _enum(["http", "https"]).optional()
23255
- }), GetConnectionEndpointsResultSchema), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" });
23631
+ }), GetConnectionEndpointsResultSchema), method(_void(), NotificationEndpointSchema), method(object({ baseUrl: string().nullable() }), NotificationEndpointSchema, { kind: "mutation" }), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" });
23256
23632
  /**
23257
23633
  * mesh-network — collection cap for mesh-VPN providers.
23258
23634
  *
@@ -24051,7 +24427,12 @@ var RecordingDeviceUsageSchema = object({
24051
24427
  var RecordingLocationUsageSchema = object({
24052
24428
  /** StorageLocation id; null for the legacy/degraded single-root fallback. */
24053
24429
  locationId: string().nullable(),
24054
- /** Bytes of recordings stored on this location. */
24430
+ /** Every location id sharing this row's PHYSICAL volume (aliases). One row
24431
+ * is emitted per physical disk (2026-07-29): two locations on one root
24432
+ * previously rendered as two identical "disks" with a nonsensical used
24433
+ * split — hydrate attribution across aliases is arbitrary by nature. */
24434
+ locationIds: array(string()).optional(),
24435
+ /** Bytes of recordings stored on this PHYSICAL volume (all aliases). */
24055
24436
  usedBytes: number(),
24056
24437
  /** Free bytes on the location's volume; null when capacity is unknown (remote). */
24057
24438
  availableBytes: number().nullable(),
@@ -24163,6 +24544,44 @@ method(object({
24163
24544
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
24164
24545
  kind: "query",
24165
24546
  auth: "admin"
24547
+ }), method(object({
24548
+ deviceId: number(),
24549
+ aroundMs: number(),
24550
+ preRollSec: number().min(0).max(30).default(2),
24551
+ postRollSec: number().min(0).max(30).default(5),
24552
+ maxWidth: number().int().min(120).max(1280).default(480),
24553
+ fps: number().int().min(1).max(15).default(5)
24554
+ }), object({
24555
+ gifBase64: string(),
24556
+ fromMs: number(),
24557
+ toMs: number()
24558
+ }), {
24559
+ kind: "mutation",
24560
+ auth: "admin"
24561
+ }), method(object({
24562
+ deviceId: number(),
24563
+ aroundMs: number(),
24564
+ preRollSec: number().min(0).max(30).default(3),
24565
+ postRollSec: number().min(0).max(30).default(7),
24566
+ maxWidth: number().int().min(160).max(1920).default(640)
24567
+ }), object({
24568
+ clipBase64: string(),
24569
+ mime: string(),
24570
+ fromMs: number(),
24571
+ toMs: number(),
24572
+ bytes: number().int()
24573
+ }), {
24574
+ kind: "mutation",
24575
+ auth: "admin"
24576
+ }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
24577
+ kind: "mutation",
24578
+ auth: "admin"
24579
+ }), method(object({}), array(RelocateJobSchema).readonly(), {
24580
+ kind: "query",
24581
+ auth: "admin"
24582
+ }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24583
+ kind: "mutation",
24584
+ auth: "admin"
24166
24585
  });
24167
24586
  /**
24168
24587
  * `recordingExport` cap — render a footage time range into a single downloadable
@@ -25076,6 +25495,12 @@ Object.freeze({
25076
25495
  addonId: null,
25077
25496
  access: "delete"
25078
25497
  },
25498
+ "backup.deleteSchedule": {
25499
+ capName: "backup",
25500
+ capScope: "system",
25501
+ addonId: null,
25502
+ access: "delete"
25503
+ },
25079
25504
  "backup.getEntries": {
25080
25505
  capName: "backup",
25081
25506
  capScope: "system",
@@ -25106,6 +25531,12 @@ Object.freeze({
25106
25531
  addonId: null,
25107
25532
  access: "view"
25108
25533
  },
25534
+ "backup.listSchedules": {
25535
+ capName: "backup",
25536
+ capScope: "system",
25537
+ addonId: null,
25538
+ access: "view"
25539
+ },
25109
25540
  "backup.previewSchedule": {
25110
25541
  capName: "backup",
25111
25542
  capScope: "system",
@@ -25130,6 +25561,12 @@ Object.freeze({
25130
25561
  addonId: null,
25131
25562
  access: "create"
25132
25563
  },
25564
+ "backup.upsertSchedule": {
25565
+ capName: "backup",
25566
+ capScope: "system",
25567
+ addonId: null,
25568
+ access: "create"
25569
+ },
25133
25570
  "battery.wakeForStream": {
25134
25571
  capName: "battery",
25135
25572
  capScope: "device",
@@ -26630,6 +27067,12 @@ Object.freeze({
26630
27067
  addonId: null,
26631
27068
  access: "view"
26632
27069
  },
27070
+ "localNetwork.getNotificationEndpoint": {
27071
+ capName: "local-network",
27072
+ capScope: "system",
27073
+ addonId: null,
27074
+ access: "view"
27075
+ },
26633
27076
  "localNetwork.getPreferred": {
26634
27077
  capName: "local-network",
26635
27078
  capScope: "system",
@@ -26654,6 +27097,12 @@ Object.freeze({
26654
27097
  addonId: null,
26655
27098
  access: "create"
26656
27099
  },
27100
+ "localNetwork.setNotificationEndpoint": {
27101
+ capName: "local-network",
27102
+ capScope: "system",
27103
+ addonId: null,
27104
+ access: "create"
27105
+ },
26657
27106
  "lockControl.lock": {
26658
27107
  capName: "lock-control",
26659
27108
  capScope: "device",
@@ -27296,6 +27745,12 @@ Object.freeze({
27296
27745
  addonId: null,
27297
27746
  access: "create"
27298
27747
  },
27748
+ "pipelineAnalytics.cancelMediaRelocate": {
27749
+ capName: "pipeline-analytics",
27750
+ capScope: "device",
27751
+ addonId: null,
27752
+ access: "create"
27753
+ },
27299
27754
  "pipelineAnalytics.clearTracks": {
27300
27755
  capName: "pipeline-analytics",
27301
27756
  capScope: "device",
@@ -27350,6 +27805,12 @@ Object.freeze({
27350
27805
  addonId: null,
27351
27806
  access: "view"
27352
27807
  },
27808
+ "pipelineAnalytics.getMediaRelocateStatus": {
27809
+ capName: "pipeline-analytics",
27810
+ capScope: "device",
27811
+ addonId: null,
27812
+ access: "view"
27813
+ },
27353
27814
  "pipelineAnalytics.getMotionEvents": {
27354
27815
  capName: "pipeline-analytics",
27355
27816
  capScope: "device",
@@ -27422,6 +27883,12 @@ Object.freeze({
27422
27883
  addonId: null,
27423
27884
  access: "create"
27424
27885
  },
27886
+ "pipelineAnalytics.relocateMedia": {
27887
+ capName: "pipeline-analytics",
27888
+ capScope: "device",
27889
+ addonId: null,
27890
+ access: "create"
27891
+ },
27425
27892
  "pipelineAnalytics.searchObjectEvents": {
27426
27893
  capName: "pipeline-analytics",
27427
27894
  capScope: "device",
@@ -28184,6 +28651,12 @@ Object.freeze({
28184
28651
  addonId: null,
28185
28652
  access: "create"
28186
28653
  },
28654
+ "recording.cancelRelocate": {
28655
+ capName: "recording",
28656
+ capScope: "system",
28657
+ addonId: null,
28658
+ access: "create"
28659
+ },
28187
28660
  "recording.deleteFootprint": {
28188
28661
  capName: "recording",
28189
28662
  capScope: "system",
@@ -28214,6 +28687,12 @@ Object.freeze({
28214
28687
  addonId: null,
28215
28688
  access: "view"
28216
28689
  },
28690
+ "recording.getRelocateStatus": {
28691
+ capName: "recording",
28692
+ capScope: "system",
28693
+ addonId: null,
28694
+ access: "view"
28695
+ },
28217
28696
  "recording.getStorageUsage": {
28218
28697
  capName: "recording",
28219
28698
  capScope: "system",
@@ -28244,6 +28723,24 @@ Object.freeze({
28244
28723
  addonId: null,
28245
28724
  access: "view"
28246
28725
  },
28726
+ "recording.relocateFootage": {
28727
+ capName: "recording",
28728
+ capScope: "system",
28729
+ addonId: null,
28730
+ access: "create"
28731
+ },
28732
+ "recording.renderClip": {
28733
+ capName: "recording",
28734
+ capScope: "system",
28735
+ addonId: null,
28736
+ access: "create"
28737
+ },
28738
+ "recording.renderGif": {
28739
+ capName: "recording",
28740
+ capScope: "system",
28741
+ addonId: null,
28742
+ access: "create"
28743
+ },
28247
28744
  "recording.rescanStorage": {
28248
28745
  capName: "recording",
28249
28746
  capScope: "system",
@@ -28838,6 +29335,12 @@ Object.freeze({
28838
29335
  addonId: null,
28839
29336
  access: "create"
28840
29337
  },
29338
+ "streamBroker.renderPreBufferClip": {
29339
+ capName: "stream-broker",
29340
+ capScope: "system",
29341
+ addonId: null,
29342
+ access: "create"
29343
+ },
28841
29344
  "streamBroker.restartProfile": {
28842
29345
  capName: "stream-broker",
28843
29346
  capScope: "system",
@@ -28964,6 +29467,36 @@ Object.freeze({
28964
29467
  addonId: null,
28965
29468
  access: "create"
28966
29469
  },
29470
+ "terminalSession.close": {
29471
+ capName: "terminal-session",
29472
+ capScope: "system",
29473
+ addonId: null,
29474
+ access: "create"
29475
+ },
29476
+ "terminalSession.listProfiles": {
29477
+ capName: "terminal-session",
29478
+ capScope: "system",
29479
+ addonId: null,
29480
+ access: "view"
29481
+ },
29482
+ "terminalSession.listSessions": {
29483
+ capName: "terminal-session",
29484
+ capScope: "system",
29485
+ addonId: null,
29486
+ access: "view"
29487
+ },
29488
+ "terminalSession.openSession": {
29489
+ capName: "terminal-session",
29490
+ capScope: "system",
29491
+ addonId: null,
29492
+ access: "create"
29493
+ },
29494
+ "terminalSession.resize": {
29495
+ capName: "terminal-session",
29496
+ capScope: "system",
29497
+ addonId: null,
29498
+ access: "create"
29499
+ },
28967
29500
  "toast.onToast": {
28968
29501
  capName: "toast",
28969
29502
  capScope: "system",
@@ -35803,6 +36336,17 @@ var HOME_ASSISTANT_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0
35803
36336
  * the admin UI is served from.
35804
36337
  */
35805
36338
  var HA_NOTIFICATION_ICON_URL = `/addon/${HA_ROUTE_ID}/icons/${HA_ICON_KIND}`;
36339
+ /**
36340
+ * Media type of {@link HA_NOTIFICATION_ICON_URL}, stamped onto the kind
36341
+ * descriptor beside the URL.
36342
+ *
36343
+ * Not decoration: a React-Native client renders SVG and raster through
36344
+ * DIFFERENT components (`react-native-svg` vs `expo-image`, and expo-image does
36345
+ * not decode SVG on iOS/Android), and it cannot infer the type from the URL
36346
+ * because the route path carries no file extension. Without this the viewer
36347
+ * treats the icon as raster and falls back to a placeholder glyph.
36348
+ */
36349
+ var HA_NOTIFICATION_ICON_MEDIA_TYPE = "image/svg+xml";
35806
36350
  /** The icon's identity bytes plus every content coding we may serve, computed
35807
36351
  * once at module load (the icon is immutable). */
35808
36352
  var HA_ICON_IDENTITY = Buffer.from(HOME_ASSISTANT_SVG, "utf8");
@@ -36239,10 +36783,14 @@ function createHaNotificationOutputProvider(deps) {
36239
36783
  }
36240
36784
  return {
36241
36785
  listTargetKinds: async () => {
36242
- return [deps.iconUrl !== void 0 ? {
36786
+ return [deps.iconUrl === void 0 ? descriptor : deps.iconMediaType !== void 0 ? {
36787
+ ...descriptor,
36788
+ iconUrl: deps.iconUrl,
36789
+ iconMediaType: deps.iconMediaType
36790
+ } : {
36243
36791
  ...descriptor,
36244
36792
  iconUrl: deps.iconUrl
36245
- } : descriptor];
36793
+ }];
36246
36794
  },
36247
36795
  listTargets: async () => {
36248
36796
  return (await store.list()).map((target) => ({
@@ -36826,6 +37374,7 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
36826
37374
  return createHaNotificationOutputProvider({
36827
37375
  addonId: this.ctx.id,
36828
37376
  iconUrl: HA_NOTIFICATION_ICON_URL,
37377
+ iconMediaType: HA_NOTIFICATION_ICON_MEDIA_TYPE,
36829
37378
  store,
36830
37379
  publish: async (brokerId, service, serviceData) => {
36831
37380
  await this.requireRegistry().publish(brokerId, {