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