@camstack/addon-pipeline 1.2.10 → 1.2.12

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 (28) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-DNjy_sr4.js → dist-B89zyHyl.js} +1265 -1176
  6. package/dist/{dist-owmpaeIY.mjs → dist-DMiEnBkk.mjs} +1265 -1176
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +2 -2
  10. package/dist/pipeline-runner/index.mjs +2 -2
  11. package/dist/recorder/index.js +148 -42
  12. package/dist/recorder/index.mjs +148 -42
  13. package/dist/{step-definitions-C2bNlww3.js → step-definitions-DLgHGq8f.js} +1 -1
  14. package/dist/{step-definitions-CkvctwuS.mjs → step-definitions-NeFJ6mFi.mjs} +1 -1
  15. package/dist/stream-broker/_stub.js +1 -1
  16. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Co2DA61E.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CAvWbzaf.mjs} +2 -2
  17. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DHQsa56B.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DbC3MMjB.mjs} +1 -1
  18. package/dist/stream-broker/{hostInit-COV9efWK.mjs → hostInit-Do62nL9c.mjs} +2 -2
  19. package/dist/stream-broker/index.js +1 -1
  20. package/dist/stream-broker/index.mjs +1 -1
  21. package/dist/stream-broker/remoteEntry.js +1 -1
  22. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CftzR7Pj.js → MaskShapeCanvas-DI4BY7W2-CNyCTsIC.js} +1 -1
  23. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DPgVIdEn.js → MotionZonesSettings-NcxxQN8r-DOdX9Yp1.js} +1 -1
  24. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-D0HqiXHW.js → PrivacyMaskSettings-APgPLF7p-9huXsgpT.js} +1 -1
  25. package/embed-dist/assets/index-BxOCtm8E.js +116 -0
  26. package/embed-dist/index.html +1 -1
  27. package/package.json +1 -1
  28. package/embed-dist/assets/index-DRtbV_en.js +0 -116
@@ -9451,397 +9451,1035 @@ var AccessoryKind = {
9451
9451
  AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
9452
9452
  DeviceFeature.BatteryOperated;
9453
9453
  /**
9454
- * Generic device-level status snapshot. Auto-registered by `BaseDevice`
9455
- * for every device, regardless of provider the kernel needs a uniform
9456
- * cap-keyed slice for the basic device flags every consumer expects to
9457
- * read across processes (the `online` flag in particular). Driver-specific
9458
- * caps (`battery`, `doorbell`, …) carry their domain-specific state on
9459
- * their own slices.
9454
+ * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
9455
+ * motion-zones, and the detection zones/lines editor all speak this one
9456
+ * language so a single drawing-plane editor and the providers stay
9457
+ * decoupled from each cap's storage.
9460
9458
  *
9461
- * Pattern is identical to `battery`: schema-bearing `runtimeState`,
9462
- * empty `methods`, single change event. Reads land at
9463
- * `runtimeState.getCapState('device-status')`; writes at
9464
- * `runtimeState.setCapState('device-status', …)`. Cross-process
9465
- * consumers reach the same data via the `device-state` cap router
9466
- * (`getCapSlice({deviceId, capName: 'device-status'})`).
9459
+ * All coordinates are normalized 0..1 of the camera frame (top-left
9460
+ * origin). Each cap composes the SUBSET of shape kinds it supports and
9461
+ * advertises it via `supportedShapes` in its `getOptions`.
9467
9462
  */
9468
- var DeviceStatusSchema = object({
9469
- /**
9470
- * Device-level liveness. Drivers flip via `markOnline(boolean)` on
9471
- * `BaseDevice`. Provider semantics vary — RTSP aggregates broker
9472
- * stream-health, Reolink reads firmware push events, ONVIF tracks
9473
- * ping responses. This cap intentionally does NOT prescribe which
9474
- * signal drives the flag.
9475
- */
9476
- online: boolean(),
9477
- /** Ms epoch of the last `online` transition. Lets consumers tell
9478
- * apart "just came online" from "still online". */
9479
- lastChangedAt: number()
9463
+ /** A normalized 0..1 point (top-left origin). */
9464
+ var MaskPointSchema = object({
9465
+ x: number(),
9466
+ y: number()
9480
9467
  });
9481
- object({
9482
- deviceId: number(),
9483
- status: DeviceStatusSchema
9468
+ /** Axis-aligned rectangle (normalized 0..1). */
9469
+ var MaskRectShapeSchema = object({
9470
+ kind: literal("rect"),
9471
+ x: number(),
9472
+ y: number(),
9473
+ width: number(),
9474
+ height: number()
9475
+ });
9476
+ /** Free polygon — an ordered list of normalized vertices (≥3). */
9477
+ var MaskPolygonShapeSchema = object({
9478
+ kind: literal("polygon"),
9479
+ points: array(MaskPointSchema)
9480
+ });
9481
+ /** Boolean cell grid — row-major, length === gridWidth*gridHeight. */
9482
+ var MaskGridShapeSchema = object({
9483
+ kind: literal("grid"),
9484
+ gridWidth: number(),
9485
+ gridHeight: number(),
9486
+ cells: array(boolean())
9487
+ });
9488
+ discriminatedUnion("kind", [
9489
+ MaskRectShapeSchema,
9490
+ MaskPolygonShapeSchema,
9491
+ MaskGridShapeSchema,
9492
+ object({
9493
+ kind: literal("line"),
9494
+ points: array(MaskPointSchema)
9495
+ })
9496
+ ]);
9497
+ /** Every shape-kind discriminant, for `supportedShapes` advertisement. */
9498
+ var MaskShapeKindSchema = _enum([
9499
+ "rect",
9500
+ "polygon",
9501
+ "grid",
9502
+ "line"
9503
+ ]);
9504
+ /** Polygon vertex bounds when a cap supports 'polygon' (e.g. Hikvision {min:4,max:4}). */
9505
+ var MaskPolygonVerticesSchema = object({
9506
+ min: number(),
9507
+ max: number()
9508
+ });
9509
+ /** Grid dimensions when a cap supports 'grid'. */
9510
+ var MaskGridDimsSchema = object({
9511
+ width: number(),
9512
+ height: number()
9484
9513
  });
9485
9514
  /**
9486
- * Per-device feature/identity probe slice. Holds the runtime-resolved
9487
- * truth about what a device CAN do — which the kernel uses to:
9488
- * 1. Reconcile accessory children (hub-children spawn siren/floodlight/PIR
9489
- * based on what the firmware actually advertises).
9490
- * 2. Compute the public `features: DeviceFeature[]` array surfaced via
9491
- * `device-manager.listAll`.
9492
- * 3. Decide which optional caps (PTZ, intercom, doorbell, battery, …)
9493
- * to register on the device's capability surface.
9515
+ * notification-rules the Notification Center rule surface (P1 core).
9494
9516
  *
9495
- * Auto-registered by `BaseDevice` for every device. Drivers populate the
9496
- * slice from `onProbe()` (kernel calls it once after register, before
9497
- * accessory reconciliation). Consumers read via:
9498
- * `runtimeState.getCapState<FeatureProbeStatus>('feature-probe')`
9517
+ * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
9518
+ * (operator decisions D-1/D-2/D-3 are binding):
9499
9519
  *
9500
- * `flags` is an open record so each driver carries its own keys without
9501
- * a centralized schema bottleneck Reolink writes `hasPtz/hasIntercom`,
9502
- * Hikvision writes `hasSupplementalLight/hasAlarmIo`, etc.
9520
+ * - D-2: rule EVALUATION lives in `addon-post-analysis` (the
9521
+ * `notification-center` module), hooked on the durable persistence
9522
+ * moments (object-event insert, TrackCloser.closeExpired) with a
9523
+ * persisted outbox + retry — never the lossy telemetry bus (D8).
9524
+ * - D-3: urgency belongs to the RULE. `delivery: 'immediate'` fires on the
9525
+ * FIRST persisted detection matching the conditions (per-track dedup,
9526
+ * `maxPerTrack` fixed at 1 — see {@link NC_MAX_PER_TRACK_IMMEDIATE});
9527
+ * `delivery: 'track-end'` evaluates the finalized track record at close.
9528
+ * - DISPATCH stays behind `notification-output` (rules reference targets
9529
+ * by id; per-backend params are a passthrough blob capped by the
9530
+ * target kind's own caps/degrade engine).
9503
9531
  *
9504
- * Replaces the older driver-local `deviceCache.has*` blob: the per-device
9505
- * config is for operator-edited overrides + UI snapshots; runtime probe
9506
- * results belong in runtime-state where the kernel handles persistence,
9507
- * cross-process mirroring, and reactive updates.
9532
+ * P1 scope: admin-authored rules only (`createdBy` stamped from the
9533
+ * server-injected caller identity the first `caller: 'required'`
9534
+ * adopter). The P1 condition subset is: devices, classes(+exclude),
9535
+ * minConfidence, admin zones (any/all + exclude), weekly schedule
9536
+ * windows, and the optional label/identity/plate matchers. User rules,
9537
+ * private zones, per-recipient fan-out and the wider condition table are
9538
+ * P2+ (see spec §7).
9539
+ *
9540
+ * All schemas here are the single source of truth — `NcRule` etc. are
9541
+ * `z.infer` exports; no duplicate interfaces (the advanced-notifier
9542
+ * schema/interface drift is explicitly not repeated).
9508
9543
  */
9509
- var FeatureProbeStatusSchema = object({
9544
+ /**
9545
+ * D-3: the trigger/urgency of a rule — which persistence moment evaluates it.
9546
+ * The value maps 1:1 onto the evaluated record kind:
9547
+ * - `immediate` ↔ object-event persist (lowest-latency detection burst)
9548
+ * - `track-end` ↔ TrackCloser.closeExpired (finalized track record)
9549
+ * - `device-event` ↔ SensorEventStore insert (doorbell press / sensor state
9550
+ * change of a LINKED device, one row per linked camera)
9551
+ * - `package-event` ↔ PackageDropDetector object-event insert (a `package`
9552
+ * delivery / pick-up)
9553
+ *
9554
+ * `immediate`/`track-end` carry the D-3 urgency semantics; `device-event`/
9555
+ * `package-event` are pure trigger kinds (no urgency dimension). Extending
9556
+ * this one field keeps the schema additive — a rule still declares exactly
9557
+ * one trigger.
9558
+ */
9559
+ var NcDeliverySchema = _enum([
9560
+ "immediate",
9561
+ "track-end",
9562
+ "device-event",
9563
+ "package-event"
9564
+ ]);
9565
+ /** Weekly schedule — OR of windows; absence on the rule = always active. */
9566
+ var NcScheduleSchema = object({
9567
+ windows: array(object({
9568
+ /** Days of week the window STARTS on (0 = Sunday … 6 = Saturday). */
9569
+ days: array(number().int().min(0).max(6)).min(1),
9570
+ startMinute: number().int().min(0).max(1439),
9571
+ endMinute: number().int().min(0).max(1439)
9572
+ })).min(1),
9573
+ /** IANA timezone; default = hub host timezone. */
9574
+ timezone: string().optional(),
9575
+ /** Active OUTSIDE the windows (e.g. "only outside business hours"). */
9576
+ invert: boolean().optional()
9577
+ });
9578
+ /** Fuzzy plate matcher — OCR noise makes exact match useless (spec row 12/13). */
9579
+ var NcPlateMatcherSchema = object({
9580
+ values: array(string().min(1)).min(1),
9581
+ /** Max Levenshtein distance after normalization (uppercase alphanumeric). */
9582
+ maxDistance: number().int().min(0).max(3).default(1)
9583
+ });
9584
+ /**
9585
+ * Occupancy condition (DEVICE-EVENT trigger). Fires on a ZoneAnalytics
9586
+ * occupancy edge for a device — optionally narrowed to a single admin
9587
+ * `zoneId` and/or object `className`. `op` selects the edge/threshold:
9588
+ * - `became-occupied` (default) — count crossed 0 → ≥ `count`
9589
+ * - `became-free` — count crossed ≥ `count` → below it
9590
+ * - `>=` / `<=` — count is at/over or at/under `count`
9591
+ * `sustainSeconds` requires the condition hold continuously that long
9592
+ * before firing (debounces flicker; 0 = fire on the first matching edge).
9593
+ * Fail-closed: no ZoneAnalytics snapshot / missing zone / null snapshot ⇒
9594
+ * the condition never matches. Confirmed edge-state survives addon restarts
9595
+ * (declared SQLite collection, reseeded on boot).
9596
+ */
9597
+ var NcOccupancyConditionSchema = object({
9598
+ /** Admin zone id to scope the count to; absent = whole-frame occupancy. */
9599
+ zoneId: string().optional(),
9600
+ /** Object class to count; absent = any class. */
9601
+ className: string().optional(),
9602
+ op: _enum([
9603
+ "became-occupied",
9604
+ "became-free",
9605
+ ">=",
9606
+ "<="
9607
+ ]).default("became-occupied"),
9608
+ count: number().int().min(0).default(1),
9609
+ sustainSeconds: number().int().min(0).max(3600).default(15)
9610
+ });
9611
+ /** Admin-zone membership condition (zone IDs as stamped by the ZoneEngine). */
9612
+ var NcZoneConditionSchema = object({
9613
+ ids: array(string().min(1)).min(1),
9614
+ /** Quantifier over `ids` — at least one / every one visited. */
9615
+ match: _enum(["any", "all"]).default("any")
9616
+ });
9617
+ /**
9618
+ * The P1 condition set — a flat AND of groups; absent group = pass;
9619
+ * membership lists are OR within the list (spec §2.3).
9620
+ */
9621
+ var NcConditionsSchema = object({
9622
+ /** Device scope — absent = all devices. */
9623
+ devices: array(number()).optional(),
9624
+ /** Detector class names (any overlap with the record's class set). */
9625
+ classes: array(string().min(1)).optional(),
9626
+ /** Veto classes — any overlap fails the rule. */
9627
+ classesExclude: array(string().min(1)).optional(),
9628
+ /** Minimum detection confidence 0–1 (fails when the record has none). */
9629
+ minConfidence: number().min(0).max(1).optional(),
9630
+ /** Admin zone membership over event `zones` / track `zonesVisited`. */
9631
+ zones: NcZoneConditionSchema.optional(),
9632
+ /** Veto zones — any hit fails the rule. */
9633
+ zonesExclude: array(string().min(1)).optional(),
9510
9634
  /**
9511
- * Driver-specific flag bag. Each driver picks its own key names — the
9512
- * cap deliberately does NOT enforce a closed enum here. Reolink keys:
9513
- * `hasPtz`, `hasIntercom`, `hasDoorbell`, `hasFloodlight`, `hasSiren`,
9514
- * `hasPirSensor`, `hasAutotrack`, `hasBattery`. Hikvision keys:
9515
- * `hasSupplementalLight`, `lightHasWhiteLight`, `hasAlarmIo`, `hasPtz`.
9635
+ * Exact (case-insensitive) match on the record's collapsed `label`
9636
+ * (identity name / plate text / subclass).
9516
9637
  */
9517
- flags: record(string(), unknown()),
9638
+ labelEquals: array(string().min(1)).optional(),
9518
9639
  /**
9519
- * Coarse driver-classification lets cross-process consumers tell apart
9520
- * cameras / battery-cams / NVRs without re-running the probe. `null`
9521
- * before the first probe completes.
9640
+ * Identity matcher. P1 boundary: matched against the record's collapsed
9641
+ * `label` (the identity display name propagated by the face pipeline) —
9642
+ * identity-ID matching rides in P2 when identity ids reach the record.
9522
9643
  */
9523
- deviceType: string().nullable(),
9524
- /** Camera/firmware model string. `null` when the firmware doesn't expose it. */
9525
- model: string().nullable(),
9526
- /** Channel count for NVR/Hub devices; `1` for standalone cameras; `null` pre-probe. */
9527
- channelCount: number().nullable(),
9644
+ identities: array(string().min(1)).optional(),
9645
+ /** Fuzzy plate matcher against the record's `label` (plate text). */
9646
+ plates: NcPlateMatcherSchema.optional(),
9528
9647
  /**
9529
- * Ms epoch of the last SUCCESSFUL probe. `0` before the first probe
9530
- * completes drivers' `getAccessoryChildren()` should treat zero as
9531
- * "probe not done yet, return empty" so accessories aren't spawned
9532
- * before the firmware is queried.
9648
+ * Identity EXCLUDE — mirror of {@link identities} with `notIn` semantics.
9649
+ * Same P1 boundary: matched against the record's collapsed `label` (the
9650
+ * identity display name). A record with NO label passes (nothing to
9651
+ * exclude), unlike the include variant which fails on an absent label.
9533
9652
  */
9534
- lastProbedAt: number(),
9653
+ identitiesExclude: array(string().min(1)).optional(),
9535
9654
  /**
9536
- * Framework convention: every runtime-state slice carries this for the
9537
- * createRuntimeStateBridge stale-check helper. We keep it in sync with
9538
- * `lastProbedAt` on every write.
9539
- */
9540
- lastFetchedAt: number()
9541
- });
9542
- object({
9543
- deviceId: number(),
9544
- status: FeatureProbeStatusSchema
9655
+ * Minimum server-computed key-event importance in [0,1] (`Track.importance`).
9656
+ * TRACK-END only: importance is scored at track close, so it does not exist
9657
+ * at immediate / object-event evaluation time (see catalog `appliesTo`). At
9658
+ * close the value is threaded via the close-time info (the `Track` clone is
9659
+ * captured before the DB row is updated, so it would otherwise read stale).
9660
+ * Fails when the record carries no importance (never guess quality — the
9661
+ * `minConfidence` precedent). MVP cut: a single scalar threshold.
9662
+ */
9663
+ minImportance: number().min(0).max(1).optional(),
9664
+ /**
9665
+ * Minimum track dwell in SECONDS — `(lastSeen − firstSeen) / 1000`.
9666
+ * TRACK-END only: an `immediate` / object-event subject has no closed
9667
+ * lifespan, so a dwell condition never matches immediate delivery
9668
+ * (documented choice — the object-event record carries no `firstSeen`,
9669
+ * so dwell cannot be computed from what the subject actually carries).
9670
+ */
9671
+ minDwellSeconds: number().min(0).optional(),
9672
+ /**
9673
+ * Detection provenance filter. `any` (default / absent) matches every
9674
+ * source; otherwise the subject's source must equal it. Legacy records
9675
+ * with no stamped source are treated as `pipeline`. The union spans both
9676
+ * record kinds — object events carry `pipeline` | `onboard`, synthetic
9677
+ * tracks carry `sensor`.
9678
+ */
9679
+ source: _enum([
9680
+ "pipeline",
9681
+ "onboard",
9682
+ "sensor",
9683
+ "any"
9684
+ ]).optional(),
9685
+ /**
9686
+ * Minimum identity / plate MATCH confidence in [0,1] — DISTINCT from the
9687
+ * detector `minConfidence` (that gates the object-detection score; this
9688
+ * gates the recognition/OCR match score). Fails when the subject carries
9689
+ * no label-match confidence (never guess). TRACK-END only: the confidence
9690
+ * lives on the recognition result and reaches the subject at track close.
9691
+ *
9692
+ * What it measures precisely (plumbed at track close — the closer threads
9693
+ * the value into `NcTrackClosedInfo.labelConfidence`, the same seam as
9694
+ * `importance`): the BEST recognition match confidence observed for the
9695
+ * label the track carries at close — for a face, the peak cosine similarity
9696
+ * of the ASSIGNED identity (`FaceMatch.score`, reset on an identity switch);
9697
+ * for a plate, the peak OCR read score of the best-held plate
9698
+ * (`plateText.confidence`). When BOTH a face and a plate were recognized on
9699
+ * one track the higher of the two is used. A track that ended with no
9700
+ * confident identity/plate match carries no value, so the condition fails
9701
+ * closed for it (an un-recognized subject).
9702
+ */
9703
+ minLabelConfidence: number().min(0).max(1).optional(),
9704
+ /**
9705
+ * DEVICE-EVENT only. Raw device event-type tokens (`EventFire.eventType`,
9706
+ * e.g. a doorbell `press` / `press_long`) — matched case-insensitively
9707
+ * against the token carried on the device-event subject (extracted from the
9708
+ * event-emitter runtime slice's `lastEvent.eventType`). Fails when the
9709
+ * subject carries no token. Doorbell-pulse / passive-sensor kinds emit no
9710
+ * eventType, so gate those with {@link sensorKinds} instead.
9711
+ */
9712
+ eventTypeTokens: array(string().min(1)).optional(),
9713
+ /**
9714
+ * DEVICE-EVENT only. Sensor/control taxonomy kinds (e.g. `doorbell`,
9715
+ * `contact`, `button`, `device-event`) — matched against the persisted
9716
+ * `SensorEvent.kind` (see `sensor-event-kinds.ts`). Membership is OR.
9717
+ */
9718
+ sensorKinds: array(string().min(1)).optional(),
9719
+ /**
9720
+ * PACKAGE-EVENT only. Which package phase fires the rule — `delivered`
9721
+ * (a parked parcel appeared), `picked-up` (it departed), or `both`. Fails
9722
+ * when the subject's phase does not match (a subject always carries a phase
9723
+ * on the package-event trigger).
9724
+ */
9725
+ packagePhase: _enum([
9726
+ "delivered",
9727
+ "picked-up",
9728
+ "both"
9729
+ ]).optional(),
9730
+ /**
9731
+ * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
9732
+ * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
9733
+ * listed polygon (ZoneEngine membership semantics). Evaluated only when
9734
+ * the subject carries a bbox; absent bbox ⇒ the condition FAILS.
9735
+ */
9736
+ customZones: array(MaskPolygonShapeSchema).optional(),
9737
+ /**
9738
+ * DEVICE-EVENT only. ZoneAnalytics occupancy edge — fires when a device's
9739
+ * (optionally zone/class-scoped) occupancy count crosses the configured
9740
+ * threshold and holds for `sustainSeconds`. Fail-closed on missing
9741
+ * substrate (no snapshot / missing zone). See {@link NcOccupancyCondition}.
9742
+ */
9743
+ occupancy: NcOccupancyConditionSchema.optional()
9545
9744
  });
9546
- object({
9547
- /** Carbon dioxide concentration in ppm. */
9548
- co2Ppm: number().min(0).optional(),
9549
- /** Total volatile organic compounds in ppb. */
9550
- vocPpb: number().min(0).optional(),
9551
- /** Particulate matter 2.5 μm in µg/m³. */
9552
- pm25: number().min(0).optional(),
9553
- /** Particulate matter 10 μm in µg/m³. */
9554
- pm10: number().min(0).optional(),
9555
- /** Composite AQI value (typically 0..500). */
9556
- aqi: number().optional(),
9557
- /** Ms epoch when the slice was last updated. */
9558
- lastFetchedAt: number(),
9559
- /** Live display unit of the single metric this slice carries (e.g. HA
9560
- * `attributes.unit_of_measurement` → 'ppm' / 'ppb' / 'µg/m³'). Each
9561
- * upstream `sensor.*` entity surfaces ONE device_class, so one unit
9562
- * per slice is unambiguous. */
9563
- unit: string().optional(),
9564
- /** Suggested decimal places for numeric display.
9565
- * Populated live from the upstream source when provided (e.g. HA
9566
- * `attributes.suggested_display_precision`). Falls back to
9567
- * auto-formatting when absent. */
9568
- precision: number().int().min(0).max(10).optional()
9745
+ /** One delivery target: a `notification-output` Target ref + passthrough params. */
9746
+ var NcRuleTargetSchema = object({
9747
+ /** `notification-output` Target id. */
9748
+ targetId: string().min(1),
9749
+ /**
9750
+ * Per-backend passthrough. Recognized keys are mapped onto the canonical
9751
+ * Notification (`priority`, `level`, `sound`, `clickUrl`, `ttl`); the
9752
+ * degrade engine drops what the backend can't render.
9753
+ */
9754
+ params: record(string(), unknown()).optional()
9569
9755
  });
9570
- DeviceType.Sensor;
9571
9756
  /**
9572
- * Alarm-panel cap. Models HA `alarm_control_panel.*` on
9573
- * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
9574
- * across disarmed / armed_(home|away|night|vacation|custom_bypass) /
9575
- * arming / pending / triggered / disarming.
9576
- *
9577
- * Many panels require a PIN code on arm / disarm — the optional
9578
- * `code` field on the methods passes it through to the upstream
9579
- * service; it's NEVER persisted in the runtime slice or any event
9580
- * payload. The presence of a required code is signalled by
9581
- * `DeviceFeature.AlarmPinRequired` so the UI gates a code-entry
9582
- * field without a slice fetch.
9583
- *
9584
- * `availableModes` mirrors HA's `supported_features`-derived arm
9585
- * mode list — the UI renders only the buttons the panel accepts.
9757
+ * Media attachment policy (P1 still-image subset).
9758
+ * - `best` the best AVAILABLE subject image at dispatch time (D-3).
9759
+ * - `best-matching` the media that explains WHY the rule fired: a rule
9760
+ * matched on identities attaches the subject's `faceCrop`, one matched on
9761
+ * plates attaches the `plateCrop`; a rule with no identity/plate condition
9762
+ * (or when the specific crop is missing) degrades to `best`, then
9763
+ * `keyFrame`, then no attachment never delaying the send. The matched
9764
+ * condition summary is frozen on the outbox row at enqueue (like the rule
9765
+ * name), so the choice never drifts from the record that fired it.
9766
+ * - `keyFrame` the clean scene frame (no subject box).
9767
+ * - `none` no attachment.
9586
9768
  */
9587
- var AlarmStateSchema = _enum([
9588
- "disarmed",
9589
- "armed_home",
9590
- "armed_away",
9591
- "armed_night",
9592
- "armed_vacation",
9593
- "armed_custom_bypass",
9594
- "arming",
9595
- "disarming",
9596
- "pending",
9597
- "triggered"
9598
- ]);
9599
- var AlarmArmModeSchema = _enum([
9600
- "home",
9601
- "away",
9602
- "night",
9603
- "vacation",
9604
- "custom_bypass"
9605
- ]);
9606
- object({
9607
- /** Current lifecycle state. */
9608
- state: AlarmStateSchema,
9609
- /** Subset of arm modes the panel accepts. UI renders one button per
9610
- * mode in this list. */
9611
- availableModes: array(AlarmArmModeSchema),
9612
- /** Whether the panel requires a PIN on arm / disarm. Mirrors
9613
- * `DeviceFeature.AlarmPinRequired` for slice consumers. */
9614
- requiresCode: boolean(),
9615
- /** Ms epoch when the slice was last updated. */
9616
- lastChangedAt: number()
9617
- });
9618
- DeviceType.AlarmPanel, method(object({
9619
- deviceId: number().int().nonnegative(),
9620
- mode: AlarmArmModeSchema,
9621
- /** Optional PIN code. Required when `requiresCode === true`.
9622
- * Passed through to the upstream service; never persisted. */
9623
- code: string().min(1).optional()
9624
- }), _void(), {
9625
- kind: "mutation",
9626
- auth: "admin"
9627
- }), method(object({
9628
- deviceId: number().int().nonnegative(),
9629
- code: string().min(1).optional()
9630
- }), _void(), {
9631
- kind: "mutation",
9632
- auth: "admin"
9633
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
9634
- kind: "mutation",
9635
- auth: "admin"
9769
+ var NcMediaPolicySchema = object({ attach: _enum([
9770
+ "best",
9771
+ "best-matching",
9772
+ "keyFrame",
9773
+ "none"
9774
+ ]).default("best") });
9775
+ /** Throttle — cooldown survives restarts (rebuilt from the outbox on boot). */
9776
+ var NcThrottleSchema = object({
9777
+ cooldownSec: number().int().min(0).max(86400).default(60),
9778
+ /** `rule` = one shared cooldown; `rule-device` = per-camera cooldown. */
9779
+ scope: _enum(["rule", "rule-device"]).default("rule-device")
9636
9780
  });
9637
- object({
9638
- /** Current illuminance in lux (lx). */
9639
- lux: number().min(0),
9640
- /** Ms epoch when the slice was last updated. */
9641
- lastFetchedAt: number(),
9642
- /** Live display unit from the upstream source (e.g. HA
9643
- * `attributes.unit_of_measurement`). The UI prefers this over the
9644
- * role's canonical unit. Absent → fall back to the canonical unit. */
9645
- unit: string().optional(),
9646
- /** Suggested decimal places for numeric display.
9647
- * Populated live from the upstream source when provided (e.g. HA
9648
- * `attributes.suggested_display_precision`). Falls back to
9649
- * auto-formatting when absent. */
9650
- precision: number().int().min(0).max(10).optional()
9781
+ /** Client-supplied rule fields (server stamps id/createdBy/createdAt/updatedAt). */
9782
+ var NcRuleInputSchema = object({
9783
+ name: string().min(1).max(200),
9784
+ enabled: boolean().default(true),
9785
+ delivery: NcDeliverySchema,
9786
+ conditions: NcConditionsSchema.default({}),
9787
+ schedule: NcScheduleSchema.optional(),
9788
+ targets: array(NcRuleTargetSchema).min(1),
9789
+ media: NcMediaPolicySchema.default({ attach: "best" }),
9790
+ throttle: NcThrottleSchema.default({
9791
+ cooldownSec: 60,
9792
+ scope: "rule-device"
9793
+ }),
9794
+ /** `{{var}}` templating over camera/class/label/zones/confidence/time. */
9795
+ template: object({
9796
+ title: string().max(500).optional(),
9797
+ body: string().max(2e3).optional()
9798
+ }).optional(),
9799
+ /** Canonical notification priority ordinal (1..5); per-target overridable. */
9800
+ priority: number().int().min(1).max(5).default(3),
9801
+ /**
9802
+ * Ownership/visibility key. Absent = admin/global rule (unchanged legacy
9803
+ * behaviour, visible to all, read-only in the viewer). Present = personal
9804
+ * rule owned by this userId. Server-stamped; never trusted from a client.
9805
+ */
9806
+ ownerUserId: string().optional()
9651
9807
  });
9652
- DeviceType.Sensor;
9653
9808
  /**
9654
- * Per-class audio metrics aggregated over a sliding window.
9655
- */
9656
- var AudioClassSummarySchema = object({
9657
- className: string(),
9658
- /** Number of windows (chunks) where this class was the top hit. */
9659
- hits: number().int().nonnegative(),
9660
- /** Mean score across those hits, clamped to [0,1]. */
9661
- avgScore: number().min(0).max(1),
9662
- /** Peak score in the window. */
9663
- peakScore: number().min(0).max(1)
9809
+ * Partial patch for `updateRule` any subset of the input fields, plus the
9810
+ * persisted-only {@link NcRuleSchema} `disabledTargetIds` set. The latter is
9811
+ * NOT a client-authored input field (it lives on the persisted rule, not the
9812
+ * input), so it is added here explicitly to let the store's per-target opt-out
9813
+ * toggle round-trip through the shared `update` path. Viewer opt-out mutations
9814
+ * still flow through `nc.setRuleTargetEnabled` (owner-checked), never a raw
9815
+ * `updateRule` patch.
9816
+ */
9817
+ var NcRulePatchSchema = NcRuleInputSchema.partial().extend({ disabledTargetIds: array(string()).optional() });
9818
+ /** A persisted rule. */
9819
+ var NcRuleSchema = NcRuleInputSchema.extend({
9820
+ id: string(),
9821
+ /** userId of the admin who created the rule (server-stamped caller). */
9822
+ createdBy: string(),
9823
+ createdAt: number(),
9824
+ updatedAt: number(),
9825
+ /**
9826
+ * Per-target opt-out set. A targetId here is suppressed for THIS rule at
9827
+ * send time. Only a target's OWNER may add/remove its id (server-checked
9828
+ * in `nc.setRuleTargetEnabled`). Defaults to empty.
9829
+ */
9830
+ disabledTargetIds: array(string()).default([])
9831
+ });
9832
+ var NcTestResultSchema = object({
9833
+ recordId: string(),
9834
+ recordKind: _enum([
9835
+ "object-event",
9836
+ "track",
9837
+ "device-event",
9838
+ "package-event"
9839
+ ]),
9840
+ deviceId: number(),
9841
+ timestamp: number(),
9842
+ wouldFire: boolean(),
9843
+ /** Condition id that failed (first failing group), when `wouldFire` is false. */
9844
+ failedCondition: string().optional(),
9845
+ className: string().optional(),
9846
+ label: string().optional()
9847
+ });
9848
+ var NcConditionDescriptorSchema = object({
9849
+ /** Field id inside `NcConditions` (or `'schedule'` for the rule-level group). */
9850
+ id: string(),
9851
+ group: _enum([
9852
+ "scope",
9853
+ "class",
9854
+ "zones",
9855
+ "quality",
9856
+ "label",
9857
+ "schedule",
9858
+ "device",
9859
+ "package",
9860
+ "occupancy"
9861
+ ]),
9862
+ label: string(),
9863
+ /** Editor widget the UI renders — never hardcode per-condition forms. */
9864
+ valueType: _enum([
9865
+ "deviceIdList",
9866
+ "stringList",
9867
+ "number01",
9868
+ "number",
9869
+ "sourceSelect",
9870
+ "zoneSelection",
9871
+ "zoneIdList",
9872
+ "schedule",
9873
+ "plateMatcher",
9874
+ "packagePhase",
9875
+ "polygonDraw",
9876
+ "occupancy"
9877
+ ]),
9878
+ operator: _enum([
9879
+ "in",
9880
+ "notIn",
9881
+ "anyOf",
9882
+ "allOf",
9883
+ "gte",
9884
+ "fuzzyIn",
9885
+ "withinSchedule"
9886
+ ]),
9887
+ /** Which delivery kinds the condition applies to. */
9888
+ appliesTo: array(NcDeliverySchema),
9889
+ phase: string(),
9890
+ description: string().optional()
9664
9891
  });
9665
9892
  /**
9666
- * Per-camera audio metrics snapshotemitted by the analytics frame
9667
- * handler on every `pipeline.audio-inference-result` event and
9668
- * mirrored into the `audio-metrics` device-state slice. Symmetric
9669
- * with `zone-analytics` snapshots for video every consumer
9670
- * (admin UI panel, automations, alert rules) reads via the
9671
- * canonical `device.state.audioMetrics.value` reactive handle.
9893
+ * The delivery lifecycle status of a history row a straight read of the
9894
+ * durable outbox row's own status (single source of truth):
9895
+ * - `pending` enqueued, in-flight or retrying with backoff
9896
+ * - `sent` delivered (terminal)
9897
+ * - `dead` — dead-lettered after exhausting retries / a permanent
9898
+ * backend rejection / a deleted target (terminal; carries
9899
+ * the failure `error`)
9672
9900
  *
9673
- * Aggregates are computed over a rolling `windowSec` window
9674
- * (default 60s). Past that window, classes drop out of `byClass`
9675
- * and the level history shifts forward.
9901
+ * P1 has no `suppressed-quiet-hours` / `snoozed` states — those ride the P2
9902
+ * user dimension (quiet hours / snooze) and are additive when they land.
9676
9903
  */
9677
- var AudioMetricsSnapshotSchema = object({
9678
- /** Wall-clock timestamp (ms) of the most recent audio window. */
9679
- ts: number().int(),
9680
- /** Sliding-window length (seconds) used for aggregation. */
9681
- windowSec: number().int().positive(),
9682
- /** Latest level reading from the most recent window. */
9683
- level: object({
9684
- rms: number(),
9685
- dbfs: number()
9686
- }),
9687
- /** Peak dBFS observed across the rolling window. */
9688
- peakDbfs: number(),
9689
- /** Mean dBFS across the rolling window. */
9690
- avgDbfs: number(),
9691
- /** Most recent above-threshold classification, or null on silence. */
9692
- current: object({
9693
- className: string(),
9694
- score: number().min(0).max(1),
9695
- timestamp: number().int()
9696
- }).nullable(),
9697
- /** Per-class summary across the rolling window — keys are
9698
- * `macroClass` strings (e.g. `dog_bark`, `speech`, `glass_break`). */
9699
- byClass: array(AudioClassSummarySchema).readonly()
9904
+ var NcHistoryStatusSchema = _enum([
9905
+ "pending",
9906
+ "sent",
9907
+ "dead"
9908
+ ]);
9909
+ /** The evaluated record kind a history row descends from (one per trigger). */
9910
+ var NcHistoryRecordKindSchema = _enum([
9911
+ "object-event",
9912
+ "track-end",
9913
+ "device-event",
9914
+ "package-event"
9915
+ ]);
9916
+ /** Subject summary frozen on the row at fire time (survives rule/record edits). */
9917
+ var NcHistorySubjectSchema = object({
9918
+ className: string(),
9919
+ label: string().optional(),
9920
+ confidence: number().optional(),
9921
+ zones: array(string()),
9922
+ timestamp: number()
9700
9923
  });
9701
9924
  /**
9702
- * Audio-metrics history payload a series of `AudioMetricsHistoryPoint`
9703
- * samples capped at `maxPoints` (default 1024). When the requested
9704
- * `windowSec / sampleEveryMs` would exceed the cap, the provider
9705
- * subsamples by bucketed averaging and reports the effective sample
9706
- * spacing on `effectiveSampleEveryMs` so the UI can label the x-axis.
9925
+ * One delivery-history row. This is a read-only VIEW over the durable
9926
+ * outbox row (single source of truth the same row the drain loop drives;
9927
+ * NO second write path, so history can never drift from delivery state).
9928
+ * The §3.2 fields map directly: `ruleId`/`targetId`/`deviceId` are columns,
9929
+ * `eventRef` is `recordKind`+`recordId`, `timestamps` are `createdAt`
9930
+ * (fire) / `updatedAt` (last transition), `status` + `error` are the
9931
+ * lifecycle. `ruleName` + `subject` are the intent snapshot frozen at
9932
+ * enqueue. `userId?` (per-recipient history) is P2 — no user dimension in
9933
+ * P1 (admin scope only).
9707
9934
  */
9708
- var AudioMetricsHistorySchema = object({
9709
- points: array(object({
9710
- /** Wall-clock ms when this sample was recorded. */
9711
- ts: number().int(),
9712
- /** Instantaneous dBFS level at sample time. `null` for windows where
9713
- * the source had no level reading (rare; happens at decode startup). */
9714
- dbfs: number().nullable(),
9715
- /** Rolling-window peak dBFS at sample time. Same window the live
9716
- * snapshot reports. */
9717
- peakDbfs: number(),
9718
- /** Rolling-window mean dBFS at sample time. */
9719
- avgDbfs: number(),
9720
- /** Dominant above-threshold class at sample time, or null on silence. */
9721
- topClass: string().nullable(),
9722
- /** Score of the dominant class (`null` whenever `topClass` is null). */
9723
- topScore: number().min(0).max(1).nullable()
9724
- })).readonly(),
9725
- /** Actual ms between adjacent samples after any subsampling. */
9726
- effectiveSampleEveryMs: number().int().positive(),
9727
- /** Wall-clock window covered by `points` (`points[N-1].ts - points[0].ts`),
9728
- * or `0` when there's fewer than 2 samples. */
9729
- windowMsActual: number().int().nonnegative()
9730
- });
9731
- DeviceType.Camera, method(object({ deviceId: number() }), AudioMetricsSnapshotSchema.nullable()), method(object({
9935
+ var NcHistoryEntrySchema = object({
9936
+ /** Outbox row id — the stable dedup id `ruleId:dedupRef:targetId`. */
9937
+ id: string(),
9938
+ ruleId: string(),
9939
+ /** Rule name frozen at fire time (outlives a later rename / delete). */
9940
+ ruleName: string(),
9941
+ /** The rule urgency/trigger that produced this delivery. */
9942
+ delivery: NcDeliverySchema,
9943
+ targetId: string(),
9732
9944
  deviceId: number(),
9733
- /** History window in seconds. Default 300 (5 minutes).
9734
- * Provider clamps to its retention cap if larger. */
9735
- windowSec: number().int().positive().optional(),
9736
- /** Target sample interval in ms. Default 1000 (1 sample/second).
9737
- * Provider clamps to natural sample rate if smaller, and
9738
- * bucket-averages when bigger than the requested window
9739
- * would produce more than `maxPoints` samples. */
9740
- sampleEveryMs: number().int().positive().optional()
9741
- }), AudioMetricsHistorySchema);
9742
- object({
9743
- /** Whether the automation is currently enabled. Disabled automations
9744
- * ignore their trigger block — manual `trigger` still works. */
9745
- enabled: boolean(),
9746
- /** Whether the automation is currently executing its action block. */
9747
- isRunning: boolean(),
9748
- /** Ms epoch of the last successful run. 0 when never run. */
9749
- lastTriggeredAt: number(),
9750
- /** Failure description from the last completed run. Null on success
9751
- * or when never run. */
9752
- lastError: string().nullable(),
9753
- /** Ms epoch when the slice was last updated. */
9754
- lastChangedAt: number()
9945
+ recordKind: NcHistoryRecordKindSchema,
9946
+ /** Event / track ref of the evaluated record (§3.2 `eventRef`). */
9947
+ recordId: string(),
9948
+ /** Present for track-scoped deliveries (object-event / track-end). */
9949
+ trackId: string().optional(),
9950
+ status: NcHistoryStatusSchema,
9951
+ /** Delivery attempts made so far. */
9952
+ attempts: number().int(),
9953
+ /** Fire time (outbox enqueue). */
9954
+ createdAt: number(),
9955
+ /** Last transition time (terminal for sent / dead). */
9956
+ updatedAt: number(),
9957
+ /** Failure detail — present on a `dead` row. */
9958
+ error: string().optional(),
9959
+ subject: NcHistorySubjectSchema
9755
9960
  });
9756
- DeviceType.Automation, method(object({ deviceId: number().int().nonnegative() }), _void(), {
9757
- kind: "mutation",
9758
- auth: "admin"
9759
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
9961
+ /**
9962
+ * Query filter for `getHistory` (spec §4.2). Every field is a narrowing
9963
+ * AND; absent = unbounded on that axis. `since`/`until` bound the fire time
9964
+ * (`createdAt`, epoch ms, inclusive). `limit` is clamped to
9965
+ * {@link NC_HISTORY_LIMIT_MAX}. `userId` (per-recipient filtering) is P2.
9966
+ */
9967
+ var NcHistoryFilterSchema = object({
9968
+ ruleId: string().optional(),
9969
+ deviceId: number().optional(),
9970
+ status: NcHistoryStatusSchema.optional(),
9971
+ since: number().optional(),
9972
+ until: number().optional(),
9973
+ limit: number().int().min(1).max(500).default(100)
9974
+ });
9975
+ 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 }), {
9760
9976
  kind: "mutation",
9761
- auth: "admin"
9977
+ auth: "admin",
9978
+ caller: "required"
9762
9979
  }), method(object({
9763
- deviceId: number().int().nonnegative(),
9764
- /** When true, fires the action block while bypassing the
9765
- * automation's condition evaluation. Gated by
9766
- * `DeviceFeature.AutomationSkipCondition`. */
9767
- skipCondition: boolean().optional()
9768
- }), _void(), {
9980
+ ruleId: string(),
9981
+ patch: NcRulePatchSchema
9982
+ }), object({ rule: NcRuleSchema }), {
9983
+ kind: "mutation",
9984
+ auth: "admin",
9985
+ caller: "required"
9986
+ }), method(object({ ruleId: string() }), object({ success: literal(true) }), {
9987
+ kind: "mutation",
9988
+ auth: "admin"
9989
+ }), method(object({
9990
+ ruleId: string(),
9991
+ enabled: boolean()
9992
+ }), object({ success: literal(true) }), {
9993
+ kind: "mutation",
9994
+ auth: "admin"
9995
+ }), method(object({
9996
+ rule: NcRuleInputSchema,
9997
+ lookbackMinutes: number().int().min(1).max(1440).default(60)
9998
+ }), object({ results: array(NcTestResultSchema) }), {
9769
9999
  kind: "mutation",
9770
10000
  auth: "admin"
10001
+ }), method(object({}), object({ catalog: array(NcConditionDescriptorSchema) })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
10002
+ /**
10003
+ * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
10004
+ *
10005
+ * Spec: `docs/superpowers/specs/2026-07-24-nc-occupancy-timelapse-design.md`
10006
+ * §3.2/§3.3.
10007
+ *
10008
+ * Deliberately NOT a capability definition and NOT an `NcRule`:
10009
+ * - Every `NcDelivery` member is a *persisted-pipeline-record* trigger. A
10010
+ * timelapse fires on a SCHEDULE WINDOW BOUNDARY, evaluates no pipeline
10011
+ * record, and produces a video it assembled itself — so it rides no
10012
+ * delivery-enum member (the enum is frozen) and no cap method. This file is
10013
+ * a plain typed schema; it does NOT go through `npm run codegen`.
10014
+ * - It shares only the delivery leg (`notification-output.send`) and the
10015
+ * persistence/ownership patterns with the Notification Center, reusing
10016
+ * {@link NcScheduleSchema} (weekly windows, midnight-crossing, invertible)
10017
+ * and {@link NcRuleTargetSchema} (target ref + passthrough params).
10018
+ *
10019
+ * Ownership is SERVER-DERIVED. `ownerUserId` / `createdBy` / `createdAt` /
10020
+ * `updatedAt` / `id` / `lastGeneratedAt` live on the PERSISTED rule only —
10021
+ * {@link TimelapseRuleInputSchema} and {@link TimelapseRulePatchSchema} do not
10022
+ * carry them, so a forged client payload can never claim or re-own a rule
10023
+ * (Zod strips unknown keys). The store stamps them from the resolved caller.
10024
+ */
10025
+ /** `{{var}}` templating over camera/rule/time — same vocabulary as `NcRule`. */
10026
+ var TimelapseTemplateSchema = object({
10027
+ title: string().max(500).optional(),
10028
+ body: string().max(2e3).optional()
10029
+ });
10030
+ var NameField = string().min(1).max(200);
10031
+ var DeviceIdsField = array(number()).min(1);
10032
+ var CadenceSecField = number().int().min(2).max(3600);
10033
+ var FramerateField = number().int().min(1).max(60);
10034
+ var TargetsField = array(NcRuleTargetSchema).min(1);
10035
+ var PriorityField = number().int().min(1).max(5);
10036
+ /**
10037
+ * Client-supplied timelapse-rule fields. The server stamps id / createdBy /
10038
+ * createdAt / updatedAt / ownerUserId / lastGeneratedAt — none of them appear
10039
+ * here (see the ownership note above).
10040
+ */
10041
+ var TimelapseRuleInputSchema = object({
10042
+ name: NameField,
10043
+ enabled: boolean().default(true),
10044
+ /** Cameras sampled by this rule — one scratch dir + one artifact per device. */
10045
+ deviceIds: DeviceIdsField,
10046
+ /**
10047
+ * Activation window(s). REQUIRED (unlike `NcRule`, where an absent schedule
10048
+ * means "always active"): a timelapse is defined by its window boundaries —
10049
+ * open clears the scratch, close assembles and delivers.
10050
+ */
10051
+ schedule: NcScheduleSchema,
10052
+ /** Force-snapshot cadence inside the window, seconds (predecessor parity). */
10053
+ cadenceSec: CadenceSecField.default(15),
10054
+ /** Output frames per second of the assembled mp4 (predecessor parity). */
10055
+ framerate: FramerateField.default(10),
10056
+ /** `notification-output` targets the finished video/thumbnail is sent to. */
10057
+ targets: TargetsField,
10058
+ template: TimelapseTemplateSchema.optional(),
10059
+ /** Canonical notification priority ordinal (1..5); per-target overridable. */
10060
+ priority: PriorityField.default(3)
10061
+ });
10062
+ object({
10063
+ name: NameField.optional(),
10064
+ enabled: boolean().optional(),
10065
+ deviceIds: DeviceIdsField.optional(),
10066
+ schedule: NcScheduleSchema.optional(),
10067
+ cadenceSec: CadenceSecField.optional(),
10068
+ framerate: FramerateField.optional(),
10069
+ targets: TargetsField.optional(),
10070
+ template: TimelapseTemplateSchema.nullable().optional(),
10071
+ priority: PriorityField.optional()
10072
+ });
10073
+ TimelapseRuleInputSchema.extend({
10074
+ id: string(),
10075
+ /**
10076
+ * Ownership/visibility key. Absent = admin/global rule (visible to all).
10077
+ * Present = personal rule owned by this userId. Server-stamped from the
10078
+ * resolved caller; never trusted from a client payload.
10079
+ */
10080
+ ownerUserId: string().optional(),
10081
+ /**
10082
+ * Epoch-ms of the last successful generation — the 1-hour re-generation
10083
+ * guard's durable state (predecessor parity). Absent = never generated.
10084
+ */
10085
+ lastGeneratedAt: number().optional(),
10086
+ /** userId of the caller who created the rule (server-stamped). */
10087
+ createdBy: string(),
10088
+ createdAt: number(),
10089
+ updatedAt: number()
9771
10090
  });
9772
10091
  /**
9773
- * Battery status snapshot. Emitted by providers whose device is
9774
- * battery-operated (cameras with `DeviceFeature.BatteryOperated`,
9775
- * future sensor/button accessories). Consumers build their own "low
9776
- * battery" alerting on top — the cap deliberately does NOT enforce a
9777
- * threshold.
10092
+ * Generic device-level status snapshot. Auto-registered by `BaseDevice`
10093
+ * for every device, regardless of provider — the kernel needs a uniform
10094
+ * cap-keyed slice for the basic device flags every consumer expects to
10095
+ * read across processes (the `online` flag in particular). Driver-specific
10096
+ * caps (`battery`, `doorbell`, …) carry their domain-specific state on
10097
+ * their own slices.
10098
+ *
10099
+ * Pattern is identical to `battery`: schema-bearing `runtimeState`,
10100
+ * empty `methods`, single change event. Reads land at
10101
+ * `runtimeState.getCapState('device-status')`; writes at
10102
+ * `runtimeState.setCapState('device-status', …)`. Cross-process
10103
+ * consumers reach the same data via the `device-state` cap router
10104
+ * (`getCapSlice({deviceId, capName: 'device-status'})`).
9778
10105
  */
9779
- var BatteryStatusSchema = object({
9780
- /** 0..100 inclusive. Firmware-reported. */
9781
- percentage: number().min(0).max(100),
10106
+ var DeviceStatusSchema = object({
9782
10107
  /**
9783
- * Charging source. `'dc'` covers wall/USB adapters; `'solar'` is
9784
- * Reolink-specific for the Solar Panel 2 accessory (will become
9785
- * common on other battery cams). `'none'` means running on battery
9786
- * alone.
10108
+ * Device-level liveness. Drivers flip via `markOnline(boolean)` on
10109
+ * `BaseDevice`. Provider semantics vary RTSP aggregates broker
10110
+ * stream-health, Reolink reads firmware push events, ONVIF tracks
10111
+ * ping responses. This cap intentionally does NOT prescribe which
10112
+ * signal drives the flag.
9787
10113
  */
9788
- charging: _enum([
9789
- "dc",
9790
- "solar",
9791
- "none"
9792
- ]),
10114
+ online: boolean(),
10115
+ /** Ms epoch of the last `online` transition. Lets consumers tell
10116
+ * apart "just came online" from "still online". */
10117
+ lastChangedAt: number()
10118
+ });
10119
+ object({
10120
+ deviceId: number(),
10121
+ status: DeviceStatusSchema
10122
+ });
10123
+ /**
10124
+ * Per-device feature/identity probe slice. Holds the runtime-resolved
10125
+ * truth about what a device CAN do — which the kernel uses to:
10126
+ * 1. Reconcile accessory children (hub-children spawn siren/floodlight/PIR
10127
+ * based on what the firmware actually advertises).
10128
+ * 2. Compute the public `features: DeviceFeature[]` array surfaced via
10129
+ * `device-manager.listAll`.
10130
+ * 3. Decide which optional caps (PTZ, intercom, doorbell, battery, …)
10131
+ * to register on the device's capability surface.
10132
+ *
10133
+ * Auto-registered by `BaseDevice` for every device. Drivers populate the
10134
+ * slice from `onProbe()` (kernel calls it once after register, before
10135
+ * accessory reconciliation). Consumers read via:
10136
+ * `runtimeState.getCapState<FeatureProbeStatus>('feature-probe')`
10137
+ *
10138
+ * `flags` is an open record so each driver carries its own keys without
10139
+ * a centralized schema bottleneck — Reolink writes `hasPtz/hasIntercom`,
10140
+ * Hikvision writes `hasSupplementalLight/hasAlarmIo`, etc.
10141
+ *
10142
+ * Replaces the older driver-local `deviceCache.has*` blob: the per-device
10143
+ * config is for operator-edited overrides + UI snapshots; runtime probe
10144
+ * results belong in runtime-state where the kernel handles persistence,
10145
+ * cross-process mirroring, and reactive updates.
10146
+ */
10147
+ var FeatureProbeStatusSchema = object({
9793
10148
  /**
9794
- * True when the camera firmware has gone into low-power mode. Battery
9795
- * providers MUST avoid polling during sleep reading the battery
9796
- * wakes the camera up and drains charge.
10149
+ * Driver-specific flag bag. Each driver picks its own key names — the
10150
+ * cap deliberately does NOT enforce a closed enum here. Reolink keys:
10151
+ * `hasPtz`, `hasIntercom`, `hasDoorbell`, `hasFloodlight`, `hasSiren`,
10152
+ * `hasPirSensor`, `hasAutotrack`, `hasBattery`. Hikvision keys:
10153
+ * `hasSupplementalLight`, `lightHasWhiteLight`, `hasAlarmIo`, `hasPtz`.
9797
10154
  */
9798
- sleeping: boolean(),
9799
- /** Ms epoch of the last observation. Lets consumers reason about freshness. */
9800
- lastUpdated: number(),
10155
+ flags: record(string(), unknown()),
9801
10156
  /**
9802
- * True when the source is a BINARY low-battery indicator (HA
9803
- * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
9804
- * charge level `percentage` is then a coarse stand-in (100 = normal,
9805
- * sub-threshold = low). UI MUST render "Normal"/"Low" instead of a
9806
- * misleading exact percentage. Absent/false → genuine 0–100 % reading.
10157
+ * Coarse driver-classification lets cross-process consumers tell apart
10158
+ * cameras / battery-cams / NVRs without re-running the probe. `null`
10159
+ * before the first probe completes.
9807
10160
  */
9808
- binary: boolean().optional()
10161
+ deviceType: string().nullable(),
10162
+ /** Camera/firmware model string. `null` when the firmware doesn't expose it. */
10163
+ model: string().nullable(),
10164
+ /** Channel count for NVR/Hub devices; `1` for standalone cameras; `null` pre-probe. */
10165
+ channelCount: number().nullable(),
10166
+ /**
10167
+ * Ms epoch of the last SUCCESSFUL probe. `0` before the first probe
10168
+ * completes — drivers' `getAccessoryChildren()` should treat zero as
10169
+ * "probe not done yet, return empty" so accessories aren't spawned
10170
+ * before the firmware is queried.
10171
+ */
10172
+ lastProbedAt: number(),
10173
+ /**
10174
+ * Framework convention: every runtime-state slice carries this for the
10175
+ * createRuntimeStateBridge stale-check helper. We keep it in sync with
10176
+ * `lastProbedAt` on every write.
10177
+ */
10178
+ lastFetchedAt: number()
9809
10179
  });
9810
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, method(object({
9811
- deviceId: number(),
9812
- /** Bound on the wait. Sensible range 3000–10000ms. */
9813
- timeoutMs: number().int().min(500).max(3e4).default(8e3)
9814
- }), object({
9815
- awoke: boolean(),
9816
- durationMs: number()
9817
- }), { kind: "mutation" }), object({
10180
+ object({
9818
10181
  deviceId: number(),
9819
- status: BatteryStatusSchema
10182
+ status: FeatureProbeStatusSchema
9820
10183
  });
9821
10184
  object({
9822
- on: boolean(),
9823
- /** Ms epoch of the last transition. 0 if never observed. */
9824
- lastChangedAt: number()
10185
+ /** Carbon dioxide concentration in ppm. */
10186
+ co2Ppm: number().min(0).optional(),
10187
+ /** Total volatile organic compounds in ppb. */
10188
+ vocPpb: number().min(0).optional(),
10189
+ /** Particulate matter ≤ 2.5 μm in µg/m³. */
10190
+ pm25: number().min(0).optional(),
10191
+ /** Particulate matter ≤ 10 μm in µg/m³. */
10192
+ pm10: number().min(0).optional(),
10193
+ /** Composite AQI value (typically 0..500). */
10194
+ aqi: number().optional(),
10195
+ /** Ms epoch when the slice was last updated. */
10196
+ lastFetchedAt: number(),
10197
+ /** Live display unit of the single metric this slice carries (e.g. HA
10198
+ * `attributes.unit_of_measurement` → 'ppm' / 'ppb' / 'µg/m³'). Each
10199
+ * upstream `sensor.*` entity surfaces ONE device_class, so one unit
10200
+ * per slice is unambiguous. */
10201
+ unit: string().optional(),
10202
+ /** Suggested decimal places for numeric display.
10203
+ * Populated live from the upstream source when provided (e.g. HA
10204
+ * `attributes.suggested_display_precision`). Falls back to
10205
+ * auto-formatting when absent. */
10206
+ precision: number().int().min(0).max(10).optional()
9825
10207
  });
9826
10208
  DeviceType.Sensor;
10209
+ /**
10210
+ * Alarm-panel cap. Models HA `alarm_control_panel.*` on
10211
+ * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
10212
+ * across disarmed / armed_(home|away|night|vacation|custom_bypass) /
10213
+ * arming / pending / triggered / disarming.
10214
+ *
10215
+ * Many panels require a PIN code on arm / disarm — the optional
10216
+ * `code` field on the methods passes it through to the upstream
10217
+ * service; it's NEVER persisted in the runtime slice or any event
10218
+ * payload. The presence of a required code is signalled by
10219
+ * `DeviceFeature.AlarmPinRequired` so the UI gates a code-entry
10220
+ * field without a slice fetch.
10221
+ *
10222
+ * `availableModes` mirrors HA's `supported_features`-derived arm
10223
+ * mode list — the UI renders only the buttons the panel accepts.
10224
+ */
10225
+ var AlarmStateSchema = _enum([
10226
+ "disarmed",
10227
+ "armed_home",
10228
+ "armed_away",
10229
+ "armed_night",
10230
+ "armed_vacation",
10231
+ "armed_custom_bypass",
10232
+ "arming",
10233
+ "disarming",
10234
+ "pending",
10235
+ "triggered"
10236
+ ]);
10237
+ var AlarmArmModeSchema = _enum([
10238
+ "home",
10239
+ "away",
10240
+ "night",
10241
+ "vacation",
10242
+ "custom_bypass"
10243
+ ]);
9827
10244
  object({
9828
- /** Current level as 0..100 inclusive. Firmware-reported. */
9829
- percentage: number().min(0).max(100),
9830
- /** Ms epoch of the last operator-driven change. Useful for UI freshness. */
10245
+ /** Current lifecycle state. */
10246
+ state: AlarmStateSchema,
10247
+ /** Subset of arm modes the panel accepts. UI renders one button per
10248
+ * mode in this list. */
10249
+ availableModes: array(AlarmArmModeSchema),
10250
+ /** Whether the panel requires a PIN on arm / disarm. Mirrors
10251
+ * `DeviceFeature.AlarmPinRequired` for slice consumers. */
10252
+ requiresCode: boolean(),
10253
+ /** Ms epoch when the slice was last updated. */
9831
10254
  lastChangedAt: number()
9832
10255
  });
9833
- DeviceType.Light, method(object({
10256
+ DeviceType.AlarmPanel, method(object({
9834
10257
  deviceId: number().int().nonnegative(),
9835
- percentage: number().min(0).max(100)
10258
+ mode: AlarmArmModeSchema,
10259
+ /** Optional PIN code. Required when `requiresCode === true`.
10260
+ * Passed through to the upstream service; never persisted. */
10261
+ code: string().min(1).optional()
9836
10262
  }), _void(), {
9837
10263
  kind: "mutation",
9838
10264
  auth: "admin"
9839
- }), object({
9840
- deviceId: number(),
9841
- percentage: number().min(0).max(100),
9842
- lastChangedAt: number()
10265
+ }), method(object({
10266
+ deviceId: number().int().nonnegative(),
10267
+ code: string().min(1).optional()
10268
+ }), _void(), {
10269
+ kind: "mutation",
10270
+ auth: "admin"
10271
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
10272
+ kind: "mutation",
10273
+ auth: "admin"
9843
10274
  });
9844
- /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
10275
+ object({
10276
+ /** Current illuminance in lux (lx). */
10277
+ lux: number().min(0),
10278
+ /** Ms epoch when the slice was last updated. */
10279
+ lastFetchedAt: number(),
10280
+ /** Live display unit from the upstream source (e.g. HA
10281
+ * `attributes.unit_of_measurement`). The UI prefers this over the
10282
+ * role's canonical unit. Absent → fall back to the canonical unit. */
10283
+ unit: string().optional(),
10284
+ /** Suggested decimal places for numeric display.
10285
+ * Populated live from the upstream source when provided (e.g. HA
10286
+ * `attributes.suggested_display_precision`). Falls back to
10287
+ * auto-formatting when absent. */
10288
+ precision: number().int().min(0).max(10).optional()
10289
+ });
10290
+ DeviceType.Sensor;
10291
+ /**
10292
+ * Per-class audio metrics aggregated over a sliding window.
10293
+ */
10294
+ var AudioClassSummarySchema = object({
10295
+ className: string(),
10296
+ /** Number of windows (chunks) where this class was the top hit. */
10297
+ hits: number().int().nonnegative(),
10298
+ /** Mean score across those hits, clamped to [0,1]. */
10299
+ avgScore: number().min(0).max(1),
10300
+ /** Peak score in the window. */
10301
+ peakScore: number().min(0).max(1)
10302
+ });
10303
+ /**
10304
+ * Per-camera audio metrics snapshot — emitted by the analytics frame
10305
+ * handler on every `pipeline.audio-inference-result` event and
10306
+ * mirrored into the `audio-metrics` device-state slice. Symmetric
10307
+ * with `zone-analytics` snapshots for video — every consumer
10308
+ * (admin UI panel, automations, alert rules) reads via the
10309
+ * canonical `device.state.audioMetrics.value` reactive handle.
10310
+ *
10311
+ * Aggregates are computed over a rolling `windowSec` window
10312
+ * (default 60s). Past that window, classes drop out of `byClass`
10313
+ * and the level history shifts forward.
10314
+ */
10315
+ var AudioMetricsSnapshotSchema = object({
10316
+ /** Wall-clock timestamp (ms) of the most recent audio window. */
10317
+ ts: number().int(),
10318
+ /** Sliding-window length (seconds) used for aggregation. */
10319
+ windowSec: number().int().positive(),
10320
+ /** Latest level reading from the most recent window. */
10321
+ level: object({
10322
+ rms: number(),
10323
+ dbfs: number()
10324
+ }),
10325
+ /** Peak dBFS observed across the rolling window. */
10326
+ peakDbfs: number(),
10327
+ /** Mean dBFS across the rolling window. */
10328
+ avgDbfs: number(),
10329
+ /** Most recent above-threshold classification, or null on silence. */
10330
+ current: object({
10331
+ className: string(),
10332
+ score: number().min(0).max(1),
10333
+ timestamp: number().int()
10334
+ }).nullable(),
10335
+ /** Per-class summary across the rolling window — keys are
10336
+ * `macroClass` strings (e.g. `dog_bark`, `speech`, `glass_break`). */
10337
+ byClass: array(AudioClassSummarySchema).readonly()
10338
+ });
10339
+ /**
10340
+ * Audio-metrics history payload — a series of `AudioMetricsHistoryPoint`
10341
+ * samples capped at `maxPoints` (default 1024). When the requested
10342
+ * `windowSec / sampleEveryMs` would exceed the cap, the provider
10343
+ * subsamples by bucketed averaging and reports the effective sample
10344
+ * spacing on `effectiveSampleEveryMs` so the UI can label the x-axis.
10345
+ */
10346
+ var AudioMetricsHistorySchema = object({
10347
+ points: array(object({
10348
+ /** Wall-clock ms when this sample was recorded. */
10349
+ ts: number().int(),
10350
+ /** Instantaneous dBFS level at sample time. `null` for windows where
10351
+ * the source had no level reading (rare; happens at decode startup). */
10352
+ dbfs: number().nullable(),
10353
+ /** Rolling-window peak dBFS at sample time. Same window the live
10354
+ * snapshot reports. */
10355
+ peakDbfs: number(),
10356
+ /** Rolling-window mean dBFS at sample time. */
10357
+ avgDbfs: number(),
10358
+ /** Dominant above-threshold class at sample time, or null on silence. */
10359
+ topClass: string().nullable(),
10360
+ /** Score of the dominant class (`null` whenever `topClass` is null). */
10361
+ topScore: number().min(0).max(1).nullable()
10362
+ })).readonly(),
10363
+ /** Actual ms between adjacent samples after any subsampling. */
10364
+ effectiveSampleEveryMs: number().int().positive(),
10365
+ /** Wall-clock window covered by `points` (`points[N-1].ts - points[0].ts`),
10366
+ * or `0` when there's fewer than 2 samples. */
10367
+ windowMsActual: number().int().nonnegative()
10368
+ });
10369
+ DeviceType.Camera, method(object({ deviceId: number() }), AudioMetricsSnapshotSchema.nullable()), method(object({
10370
+ deviceId: number(),
10371
+ /** History window in seconds. Default 300 (5 minutes).
10372
+ * Provider clamps to its retention cap if larger. */
10373
+ windowSec: number().int().positive().optional(),
10374
+ /** Target sample interval in ms. Default 1000 (1 sample/second).
10375
+ * Provider clamps to natural sample rate if smaller, and
10376
+ * bucket-averages when bigger than the requested window
10377
+ * would produce more than `maxPoints` samples. */
10378
+ sampleEveryMs: number().int().positive().optional()
10379
+ }), AudioMetricsHistorySchema);
10380
+ object({
10381
+ /** Whether the automation is currently enabled. Disabled automations
10382
+ * ignore their trigger block — manual `trigger` still works. */
10383
+ enabled: boolean(),
10384
+ /** Whether the automation is currently executing its action block. */
10385
+ isRunning: boolean(),
10386
+ /** Ms epoch of the last successful run. 0 when never run. */
10387
+ lastTriggeredAt: number(),
10388
+ /** Failure description from the last completed run. Null on success
10389
+ * or when never run. */
10390
+ lastError: string().nullable(),
10391
+ /** Ms epoch when the slice was last updated. */
10392
+ lastChangedAt: number()
10393
+ });
10394
+ DeviceType.Automation, method(object({ deviceId: number().int().nonnegative() }), _void(), {
10395
+ kind: "mutation",
10396
+ auth: "admin"
10397
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
10398
+ kind: "mutation",
10399
+ auth: "admin"
10400
+ }), method(object({
10401
+ deviceId: number().int().nonnegative(),
10402
+ /** When true, fires the action block while bypassing the
10403
+ * automation's condition evaluation. Gated by
10404
+ * `DeviceFeature.AutomationSkipCondition`. */
10405
+ skipCondition: boolean().optional()
10406
+ }), _void(), {
10407
+ kind: "mutation",
10408
+ auth: "admin"
10409
+ });
10410
+ /**
10411
+ * Battery status snapshot. Emitted by providers whose device is
10412
+ * battery-operated (cameras with `DeviceFeature.BatteryOperated`,
10413
+ * future sensor/button accessories). Consumers build their own "low
10414
+ * battery" alerting on top — the cap deliberately does NOT enforce a
10415
+ * threshold.
10416
+ */
10417
+ var BatteryStatusSchema = object({
10418
+ /** 0..100 inclusive. Firmware-reported. */
10419
+ percentage: number().min(0).max(100),
10420
+ /**
10421
+ * Charging source. `'dc'` covers wall/USB adapters; `'solar'` is
10422
+ * Reolink-specific for the Solar Panel 2 accessory (will become
10423
+ * common on other battery cams). `'none'` means running on battery
10424
+ * alone.
10425
+ */
10426
+ charging: _enum([
10427
+ "dc",
10428
+ "solar",
10429
+ "none"
10430
+ ]),
10431
+ /**
10432
+ * True when the camera firmware has gone into low-power mode. Battery
10433
+ * providers MUST avoid polling during sleep — reading the battery
10434
+ * wakes the camera up and drains charge.
10435
+ */
10436
+ sleeping: boolean(),
10437
+ /** Ms epoch of the last observation. Lets consumers reason about freshness. */
10438
+ lastUpdated: number(),
10439
+ /**
10440
+ * True when the source is a BINARY low-battery indicator (HA
10441
+ * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
10442
+ * charge level — `percentage` is then a coarse stand-in (100 = normal,
10443
+ * sub-threshold = low). UI MUST render "Normal"/"Low" instead of a
10444
+ * misleading exact percentage. Absent/false → genuine 0–100 % reading.
10445
+ */
10446
+ binary: boolean().optional()
10447
+ });
10448
+ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, method(object({
10449
+ deviceId: number(),
10450
+ /** Bound on the wait. Sensible range 3000–10000ms. */
10451
+ timeoutMs: number().int().min(500).max(3e4).default(8e3)
10452
+ }), object({
10453
+ awoke: boolean(),
10454
+ durationMs: number()
10455
+ }), { kind: "mutation" }), object({
10456
+ deviceId: number(),
10457
+ status: BatteryStatusSchema
10458
+ });
10459
+ object({
10460
+ on: boolean(),
10461
+ /** Ms epoch of the last transition. 0 if never observed. */
10462
+ lastChangedAt: number()
10463
+ });
10464
+ DeviceType.Sensor;
10465
+ object({
10466
+ /** Current level as 0..100 inclusive. Firmware-reported. */
10467
+ percentage: number().min(0).max(100),
10468
+ /** Ms epoch of the last operator-driven change. Useful for UI freshness. */
10469
+ lastChangedAt: number()
10470
+ });
10471
+ DeviceType.Light, method(object({
10472
+ deviceId: number().int().nonnegative(),
10473
+ percentage: number().min(0).max(100)
10474
+ }), _void(), {
10475
+ kind: "mutation",
10476
+ auth: "admin"
10477
+ }), object({
10478
+ deviceId: number(),
10479
+ percentage: number().min(0).max(100),
10480
+ lastChangedAt: number()
10481
+ });
10482
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
9845
10483
  var StreamFormatSchema = _enum([
9846
10484
  "webrtc",
9847
10485
  "hls",
@@ -12878,84 +13516,23 @@ DeviceType.Light, DeviceType.Siren, DeviceType.Switch, method(object({
12878
13516
  lastChangedAt: number()
12879
13517
  });
12880
13518
  /**
12881
- * Shared geometry vocabulary for on-frame shape caps privacy-mask,
12882
- * motion-zones, and the detection zones/lines editor all speak this one
12883
- * language so a single drawing-plane editor and the providers stay
12884
- * decoupled from each cap's storage.
12885
- *
12886
- * All coordinates are normalized 0..1 of the camera frame (top-left
12887
- * origin). Each cap composes the SUBSET of shape kinds it supports and
12888
- * advertises it via `supportedShapes` in its `getOptions`.
13519
+ * Motion-zones share the same MaskShape vocabulary as privacy-mask — the
13520
+ * on-camera motion-detection mask is a single `grid` region (a row-major
13521
+ * boolean cell lattice the camera's onboard VMD evaluates). Composing it as
13522
+ * a region keeps one drawing-plane model across all geometry caps.
12889
13523
  */
12890
- /** A normalized 0..1 point (top-left origin). */
12891
- var MaskPointSchema = object({
12892
- x: number(),
12893
- y: number()
12894
- });
12895
- /** Axis-aligned rectangle (normalized 0..1). */
12896
- var MaskRectShapeSchema = object({
12897
- kind: literal("rect"),
12898
- x: number(),
12899
- y: number(),
12900
- width: number(),
12901
- height: number()
12902
- });
12903
- /** Free polygon — an ordered list of normalized vertices (≥3). */
12904
- var MaskPolygonShapeSchema = object({
12905
- kind: literal("polygon"),
12906
- points: array(MaskPointSchema)
13524
+ /** A motion-zone region exactly one boolean cell grid today. */
13525
+ var MotionZoneRegionSchema = object({
13526
+ id: number(),
13527
+ enabled: boolean(),
13528
+ shape: MaskGridShapeSchema
12907
13529
  });
12908
- /** Boolean cell grid — row-major, length === gridWidth*gridHeight. */
12909
- var MaskGridShapeSchema = object({
12910
- kind: literal("grid"),
12911
- gridWidth: number(),
12912
- gridHeight: number(),
12913
- cells: array(boolean())
12914
- });
12915
- discriminatedUnion("kind", [
12916
- MaskRectShapeSchema,
12917
- MaskPolygonShapeSchema,
12918
- MaskGridShapeSchema,
12919
- object({
12920
- kind: literal("line"),
12921
- points: array(MaskPointSchema)
12922
- })
12923
- ]);
12924
- /** Every shape-kind discriminant, for `supportedShapes` advertisement. */
12925
- var MaskShapeKindSchema = _enum([
12926
- "rect",
12927
- "polygon",
12928
- "grid",
12929
- "line"
12930
- ]);
12931
- /** Polygon vertex bounds when a cap supports 'polygon' (e.g. Hikvision {min:4,max:4}). */
12932
- var MaskPolygonVerticesSchema = object({
12933
- min: number(),
12934
- max: number()
12935
- });
12936
- /** Grid dimensions when a cap supports 'grid'. */
12937
- var MaskGridDimsSchema = object({
12938
- width: number(),
12939
- height: number()
12940
- });
12941
- /**
12942
- * Motion-zones share the same MaskShape vocabulary as privacy-mask — the
12943
- * on-camera motion-detection mask is a single `grid` region (a row-major
12944
- * boolean cell lattice the camera's onboard VMD evaluates). Composing it as
12945
- * a region keeps one drawing-plane model across all geometry caps.
12946
- */
12947
- /** A motion-zone region — exactly one boolean cell grid today. */
12948
- var MotionZoneRegionSchema = object({
12949
- id: number(),
12950
- enabled: boolean(),
12951
- shape: MaskGridShapeSchema
12952
- });
12953
- object({
12954
- enabled: boolean(),
12955
- sensitivity: number(),
12956
- /** Grid region(s). Today exactly one `grid` shape. */
12957
- regions: array(MotionZoneRegionSchema),
12958
- lastFetchedAt: number()
13530
+ object({
13531
+ enabled: boolean(),
13532
+ sensitivity: number(),
13533
+ /** Grid region(s). Today exactly one `grid` shape. */
13534
+ regions: array(MotionZoneRegionSchema),
13535
+ lastFetchedAt: number()
12959
13536
  });
12960
13537
  /** Per-camera availability — grid dims are fixed per camera model; the UI
12961
13538
  * sizes its editor from `grid`. */
@@ -16983,800 +17560,312 @@ method(object({
16983
17560
  * broker in the registry — its lifecycle is owned by the addon that
16984
17561
  * spawned it.
16985
17562
  */
16986
- var BrokerKindSchema = _enum(["external", "embedded"]);
16987
- /**
16988
- * Broker live-probe status.
16989
- *
16990
- * - `connected` — last probe completed a clean CONNACK
16991
- * - `disconnected` — no probe has run yet (cold cache)
16992
- * - `auth-failed` — CONNACK refused with auth error (RC 4 / 5)
16993
- * - `unreachable` — TCP connect timed out / refused
16994
- * - `tls-error` — TLS handshake failed (cert / SNI / cipher)
16995
- */
16996
- var BrokerStatusSchema$1 = _enum([
16997
- "connected",
16998
- "disconnected",
16999
- "auth-failed",
17000
- "unreachable",
17001
- "tls-error"
17002
- ]);
17003
- var BrokerInfoSchema = object({
17004
- id: string(),
17005
- name: string(),
17006
- url: string(),
17007
- kind: BrokerKindSchema,
17008
- status: BrokerStatusSchema$1,
17009
- latencyMs: number().nullable(),
17010
- error: string().optional(),
17011
- /** Embedded brokers only: number of MQTT clients currently connected. */
17012
- connectedClients: number().int().nonnegative().optional(),
17013
- /** Epoch ms of the last live probe (external) or aedes snapshot (embedded). */
17014
- lastCheckedAt: number().optional()
17015
- });
17016
- /**
17017
- * Connection details — what a consumer needs to call
17018
- * `mqtt.connect(url, options)`. We split URL + credentials so the
17019
- * consumer can pass them as `mqtt.connect(url, { username, password })`
17020
- * instead of stuffing creds into the URL (which leaks them into logs).
17021
- */
17022
- var BrokerConnectionDetailsSchema = object({
17023
- url: string(),
17024
- username: string().optional(),
17025
- password: string().optional(),
17026
- /**
17027
- * Suggested prefix for `clientId`. Each consumer should suffix this
17028
- * with its own discriminator (addon id, instance id) so reconnects
17029
- * don't kick each other off (MQTT spec: clientId must be unique per
17030
- * broker).
17031
- */
17032
- clientIdPrefix: string().optional()
17033
- });
17034
- var AddBrokerInputSchema = object({
17035
- name: string().min(1),
17036
- url: string().regex(/^(mqtt|mqtts|ws|wss):\/\//, "URL must start with mqtt(s):// or ws(s)://"),
17037
- username: string().optional(),
17038
- password: string().optional(),
17039
- clientIdPrefix: string().optional()
17040
- });
17041
- var AddBrokerResultSchema = object({ id: string() });
17042
- var IdInputSchema = object({ id: string() });
17043
- var TestResultSchema$1 = discriminatedUnion("ok", [object({
17044
- ok: literal(true),
17045
- latencyMs: number()
17046
- }), object({
17047
- ok: literal(false),
17048
- error: string()
17049
- })]);
17050
- var StartEmbeddedInputSchema = object({
17051
- port: number().int().min(1).max(65535).default(1883),
17052
- /** Allow anonymous connect (no username/password). Default: false. */
17053
- allowAnonymous: boolean().default(false),
17054
- /** Optional shared username/password for clients. */
17055
- username: string().optional(),
17056
- password: string().optional()
17057
- });
17058
- var StartEmbeddedResultSchema = object({
17059
- id: string(),
17060
- url: string()
17061
- });
17062
- var StatusSchema = object({
17063
- brokerCount: number(),
17064
- embeddedRunning: boolean()
17065
- });
17066
- 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);
17067
- var NetworkEndpointSchema = object({
17068
- url: string(),
17069
- hostname: string(),
17070
- port: number(),
17071
- protocol: _enum(["http", "https"])
17072
- });
17073
- var NetworkAccessStatusSchema = object({
17074
- connected: boolean(),
17075
- endpoint: NetworkEndpointSchema.nullable(),
17076
- error: string().optional()
17077
- });
17078
- /**
17079
- * Optional, richer endpoint shape returned by providers that expose
17080
- * MORE than one ingress concurrently (Tailscale Ingress with mixed
17081
- * serve+funnel rules, future ngrok multi-tunnel, …). Each entry carries
17082
- * the originating provider config (mode + sourcePort) so the
17083
- * orchestrator UI can label rows distinctly. Providers that expose only
17084
- * one endpoint just omit `listEndpoints` from their provider impl.
17085
- */
17086
- var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
17087
- /**
17088
- * Stable id within the provider — typically `<mode>-<sourcePort>` so
17089
- * the orchestrator can dedupe across `listEndpoints` polls.
17090
- */
17091
- id: string(),
17092
- /** Operator-facing label (mirrors `MeshEndpoint.label`). */
17093
- label: string(),
17094
- /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
17095
- mode: string().optional(),
17096
- /** Originating local port the ingress fronts (informational). */
17097
- sourcePort: number().optional()
17098
- });
17099
- method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
17100
- /**
17101
- * notification-output — canonical, capability-gated notification delivery.
17102
- *
17103
- * Apprise-derived model (see
17104
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
17105
- * callers emit ONE canonical `Notification`; each provider declares a
17106
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
17107
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
17108
- * message to what the kind supports — callers never special-case a service.
17109
- *
17110
- * DESIGN DECISIONS (locked):
17111
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
17112
- * `setTargetEnabled`), each provider persisting via the `settings-store`
17113
- * cap. Rationale: the admin UI needs one uniform surface across the
17114
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
17115
- * alternative would fork the UI per addon and cannot host the
17116
- * discovery→adopt flow.
17117
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
17118
- * the generated cap-mount auto-`concatCollection`-fans them across every
17119
- * registered provider (notifiers addon + HA addon) so one catalog is
17120
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
17121
- * `addonId` the generated collection router extracts from the call input.
17122
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
17123
- * transformer) + UDS MsgPack both round-trip typed arrays — already used by
17124
- * `storage` / `storage-provider` / `recording` caps over the same path. No
17125
- * base64 fallback needed.
17126
- *
17127
- * TODO (deferred, closed-set change — separate decision): add
17128
- * `providerKind: 'notify'` so notification providers surface on the unified
17129
- * admin "Integrations" page.
17130
- */
17131
- /**
17132
- * Zentik-derived typed-media enum — the superset across every kind. Each
17133
- * adapter picks what it supports and the degrade engine filters the rest.
17134
- */
17135
- var AttachmentMediaTypeSchema = _enum([
17136
- "image",
17137
- "video",
17138
- "gif",
17139
- "audio",
17140
- "icon"
17141
- ]);
17142
- /**
17143
- * A single attachment. Exactly one of `url` (remote source, most adapters
17144
- * prefer this) or `bytes` (inline source; required for Pushover-style
17145
- * bytes-only kinds) MUST be present — the degrade engine expresses a
17146
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
17147
- */
17148
- var AttachmentSchema = object({
17149
- mediaType: AttachmentMediaTypeSchema,
17150
- url: string().optional(),
17151
- bytes: _instanceof(Uint8Array).optional(),
17152
- mime: string().optional(),
17153
- name: string().optional()
17154
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
17155
- var NotificationFormatSchema = _enum([
17156
- "text",
17157
- "markdown",
17158
- "html"
17159
- ]);
17160
- /** A single tap-through action button. */
17161
- var NotificationActionSchema = object({
17162
- id: string(),
17163
- label: string(),
17164
- url: string().optional()
17165
- });
17166
- /**
17167
- * The canonical notification. `body` is the only hard field (Apprise model).
17168
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
17169
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
17170
- * the adapter maps this ordinal onto its native level. `level?` is an
17171
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
17172
- * `priority` for that one target.
17173
- */
17174
- var NotificationSchema = object({
17175
- body: string(),
17176
- title: string().optional(),
17177
- format: NotificationFormatSchema.default("text"),
17178
- priority: number().int().min(1).max(5).default(3),
17179
- level: string().optional(),
17180
- attachments: array(AttachmentSchema).optional(),
17181
- clickUrl: string().optional(),
17182
- actions: array(NotificationActionSchema).optional(),
17183
- sound: string().optional(),
17184
- ttl: number().optional(),
17185
- tag: string().optional(),
17186
- deviceId: number().optional(),
17187
- eventId: string().optional(),
17188
- metadata: record(string(), unknown()).optional()
17189
- });
17190
- /** One declared native severity/priority level for a kind. */
17191
- var TargetKindLevelSchema = object({
17192
- id: string(),
17193
- label: string(),
17194
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
17195
- ordinal: number().int().min(1).max(5).nullable(),
17196
- flags: object({
17197
- critical: boolean().optional(),
17198
- silent: boolean().optional(),
17199
- noPush: boolean().optional()
17200
- }).optional(),
17201
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
17202
- requires: array(string()).optional(),
17203
- description: string().optional()
17204
- });
17205
- /** The full capability block consulted before dispatch. */
17206
- var TargetKindCapsSchema = object({
17207
- attachments: object({
17208
- mediaTypes: array(AttachmentMediaTypeSchema),
17209
- mode: _enum([
17210
- "url",
17211
- "bytes",
17212
- "both"
17213
- ]),
17214
- max: number().int().nonnegative(),
17215
- maxBytes: number().int().positive().optional()
17216
- }),
17217
- /** Max action buttons (0 = none). */
17218
- actions: number().int().nonnegative(),
17219
- levels: array(TargetKindLevelSchema),
17220
- format: array(NotificationFormatSchema),
17221
- clickUrl: boolean(),
17222
- sound: boolean(),
17223
- ttl: boolean(),
17224
- bodyMaxLen: number().int().positive()
17225
- });
17226
- /**
17227
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
17228
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
17229
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
17230
- * the union is large and not meant for runtime validation here; the exported
17231
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
17232
- */
17233
- var ConfigSchemaPassthrough = unknown();
17234
- var TargetKindSchema = object({
17235
- kind: string(),
17236
- label: string(),
17237
- icon: string(),
17238
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
17239
- addonId: string(),
17240
- configSchema: ConfigSchemaPassthrough,
17241
- supportsDiscovery: boolean(),
17242
- caps: TargetKindCapsSchema
17243
- });
17244
- /**
17245
- * A persisted target. `config` holds secrets; providers REDACT secret fields
17246
- * (return a presence marker only) when serving `listTargets` — never
17247
- * round-trip a stored secret to the UI.
17248
- */
17249
- var TargetSchema = object({
17250
- id: string(),
17251
- name: string(),
17252
- kind: string(),
17253
- addonId: string(),
17254
- enabled: boolean(),
17255
- config: record(string(), unknown())
17256
- });
17257
- /** A discovery-surfaced candidate (config is partial + non-secret). */
17258
- var DiscoveredTargetSchema = object({
17259
- kind: string(),
17260
- suggestedName: string(),
17261
- config: record(string(), unknown())
17262
- });
17263
- /** The degrade engine's report — what was resolved / dropped / degraded. */
17264
- var RenderedAsSchema = object({
17265
- level: string(),
17266
- format: NotificationFormatSchema,
17267
- attachmentsSent: number().int().nonnegative(),
17268
- actionsSent: number().int().nonnegative(),
17269
- truncated: boolean(),
17270
- dropped: array(string())
17271
- });
17272
- var SendResultSchema = object({
17273
- success: boolean(),
17274
- error: string().optional(),
17275
- renderedAs: RenderedAsSchema.optional()
17276
- });
17277
- /** Same shape as SendResult — kept as a distinct name for the test panel. */
17278
- var TestResultSchema = SendResultSchema;
17279
- method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
17280
- kind: string(),
17281
- config: record(string(), unknown()).optional()
17282
- }), array(DiscoveredTargetSchema)), method(object({
17283
- targetId: string(),
17284
- notification: NotificationSchema
17285
- }), SendResultSchema, { kind: "mutation" }), method(object({
17286
- targetId: string(),
17287
- sample: NotificationSchema.optional()
17288
- }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
17289
- targetId: string(),
17290
- enabled: boolean()
17291
- }), _void(), { kind: "mutation" });
17292
- /**
17293
- * notification-rules — the Notification Center rule surface (P1 core).
17294
- *
17295
- * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
17296
- * (operator decisions D-1/D-2/D-3 are binding):
17297
- *
17298
- * - D-2: rule EVALUATION lives in `addon-post-analysis` (the
17299
- * `notification-center` module), hooked on the durable persistence
17300
- * moments (object-event insert, TrackCloser.closeExpired) with a
17301
- * persisted outbox + retry — never the lossy telemetry bus (D8).
17302
- * - D-3: urgency belongs to the RULE. `delivery: 'immediate'` fires on the
17303
- * FIRST persisted detection matching the conditions (per-track dedup,
17304
- * `maxPerTrack` fixed at 1 — see {@link NC_MAX_PER_TRACK_IMMEDIATE});
17305
- * `delivery: 'track-end'` evaluates the finalized track record at close.
17306
- * - DISPATCH stays behind `notification-output` (rules reference targets
17307
- * by id; per-backend params are a passthrough blob capped by the
17308
- * target kind's own caps/degrade engine).
17309
- *
17310
- * P1 scope: admin-authored rules only (`createdBy` stamped from the
17311
- * server-injected caller identity — the first `caller: 'required'`
17312
- * adopter). The P1 condition subset is: devices, classes(+exclude),
17313
- * minConfidence, admin zones (any/all + exclude), weekly schedule
17314
- * windows, and the optional label/identity/plate matchers. User rules,
17315
- * private zones, per-recipient fan-out and the wider condition table are
17316
- * P2+ (see spec §7).
17317
- *
17318
- * All schemas here are the single source of truth — `NcRule` etc. are
17319
- * `z.infer` exports; no duplicate interfaces (the advanced-notifier
17320
- * schema/interface drift is explicitly not repeated).
17321
- */
17322
- /**
17323
- * D-3: the trigger/urgency of a rule — which persistence moment evaluates it.
17324
- * The value maps 1:1 onto the evaluated record kind:
17325
- * - `immediate` ↔ object-event persist (lowest-latency detection burst)
17326
- * - `track-end` ↔ TrackCloser.closeExpired (finalized track record)
17327
- * - `device-event` ↔ SensorEventStore insert (doorbell press / sensor state
17328
- * change of a LINKED device, one row per linked camera)
17329
- * - `package-event` ↔ PackageDropDetector object-event insert (a `package`
17330
- * delivery / pick-up)
17331
- *
17332
- * `immediate`/`track-end` carry the D-3 urgency semantics; `device-event`/
17333
- * `package-event` are pure trigger kinds (no urgency dimension). Extending
17334
- * this one field keeps the schema additive — a rule still declares exactly
17335
- * one trigger.
17336
- */
17337
- var NcDeliverySchema = _enum([
17338
- "immediate",
17339
- "track-end",
17340
- "device-event",
17341
- "package-event"
17342
- ]);
17343
- /** Weekly schedule — OR of windows; absence on the rule = always active. */
17344
- var NcScheduleSchema = object({
17345
- windows: array(object({
17346
- /** Days of week the window STARTS on (0 = Sunday … 6 = Saturday). */
17347
- days: array(number().int().min(0).max(6)).min(1),
17348
- startMinute: number().int().min(0).max(1439),
17349
- endMinute: number().int().min(0).max(1439)
17350
- })).min(1),
17351
- /** IANA timezone; default = hub host timezone. */
17352
- timezone: string().optional(),
17353
- /** Active OUTSIDE the windows (e.g. "only outside business hours"). */
17354
- invert: boolean().optional()
17355
- });
17356
- /** Fuzzy plate matcher — OCR noise makes exact match useless (spec row 12/13). */
17357
- var NcPlateMatcherSchema = object({
17358
- values: array(string().min(1)).min(1),
17359
- /** Max Levenshtein distance after normalization (uppercase alphanumeric). */
17360
- maxDistance: number().int().min(0).max(3).default(1)
17361
- });
17362
- /**
17363
- * Occupancy condition (DEVICE-EVENT trigger). Fires on a ZoneAnalytics
17364
- * occupancy edge for a device — optionally narrowed to a single admin
17365
- * `zoneId` and/or object `className`. `op` selects the edge/threshold:
17366
- * - `became-occupied` (default) — count crossed 0 → ≥ `count`
17367
- * - `became-free` — count crossed ≥ `count` → below it
17368
- * - `>=` / `<=` — count is at/over or at/under `count`
17369
- * `sustainSeconds` requires the condition hold continuously that long
17370
- * before firing (debounces flicker; 0 = fire on the first matching edge).
17371
- * Fail-closed: no ZoneAnalytics snapshot / missing zone / null snapshot ⇒
17372
- * the condition never matches. Confirmed edge-state survives addon restarts
17373
- * (declared SQLite collection, reseeded on boot).
17374
- */
17375
- var NcOccupancyConditionSchema = object({
17376
- /** Admin zone id to scope the count to; absent = whole-frame occupancy. */
17377
- zoneId: string().optional(),
17378
- /** Object class to count; absent = any class. */
17379
- className: string().optional(),
17380
- op: _enum([
17381
- "became-occupied",
17382
- "became-free",
17383
- ">=",
17384
- "<="
17385
- ]).default("became-occupied"),
17386
- count: number().int().min(0).default(1),
17387
- sustainSeconds: number().int().min(0).max(3600).default(15)
17388
- });
17389
- /** Admin-zone membership condition (zone IDs as stamped by the ZoneEngine). */
17390
- var NcZoneConditionSchema = object({
17391
- ids: array(string().min(1)).min(1),
17392
- /** Quantifier over `ids` — at least one / every one visited. */
17393
- match: _enum(["any", "all"]).default("any")
17394
- });
17395
- /**
17396
- * The P1 condition set — a flat AND of groups; absent group = pass;
17397
- * membership lists are OR within the list (spec §2.3).
17398
- */
17399
- var NcConditionsSchema = object({
17400
- /** Device scope — absent = all devices. */
17401
- devices: array(number()).optional(),
17402
- /** Detector class names (any overlap with the record's class set). */
17403
- classes: array(string().min(1)).optional(),
17404
- /** Veto classes — any overlap fails the rule. */
17405
- classesExclude: array(string().min(1)).optional(),
17406
- /** Minimum detection confidence 0–1 (fails when the record has none). */
17407
- minConfidence: number().min(0).max(1).optional(),
17408
- /** Admin zone membership over event `zones` / track `zonesVisited`. */
17409
- zones: NcZoneConditionSchema.optional(),
17410
- /** Veto zones — any hit fails the rule. */
17411
- zonesExclude: array(string().min(1)).optional(),
17412
- /**
17413
- * Exact (case-insensitive) match on the record's collapsed `label`
17414
- * (identity name / plate text / subclass).
17415
- */
17416
- labelEquals: array(string().min(1)).optional(),
17417
- /**
17418
- * Identity matcher. P1 boundary: matched against the record's collapsed
17419
- * `label` (the identity display name propagated by the face pipeline) —
17420
- * identity-ID matching rides in P2 when identity ids reach the record.
17421
- */
17422
- identities: array(string().min(1)).optional(),
17423
- /** Fuzzy plate matcher against the record's `label` (plate text). */
17424
- plates: NcPlateMatcherSchema.optional(),
17425
- /**
17426
- * Identity EXCLUDE — mirror of {@link identities} with `notIn` semantics.
17427
- * Same P1 boundary: matched against the record's collapsed `label` (the
17428
- * identity display name). A record with NO label passes (nothing to
17429
- * exclude), unlike the include variant which fails on an absent label.
17430
- */
17431
- identitiesExclude: array(string().min(1)).optional(),
17432
- /**
17433
- * Minimum server-computed key-event importance in [0,1] (`Track.importance`).
17434
- * TRACK-END only: importance is scored at track close, so it does not exist
17435
- * at immediate / object-event evaluation time (see catalog `appliesTo`). At
17436
- * close the value is threaded via the close-time info (the `Track` clone is
17437
- * captured before the DB row is updated, so it would otherwise read stale).
17438
- * Fails when the record carries no importance (never guess quality — the
17439
- * `minConfidence` precedent). MVP cut: a single scalar threshold.
17440
- */
17441
- minImportance: number().min(0).max(1).optional(),
17442
- /**
17443
- * Minimum track dwell in SECONDS — `(lastSeen − firstSeen) / 1000`.
17444
- * TRACK-END only: an `immediate` / object-event subject has no closed
17445
- * lifespan, so a dwell condition never matches immediate delivery
17446
- * (documented choice — the object-event record carries no `firstSeen`,
17447
- * so dwell cannot be computed from what the subject actually carries).
17448
- */
17449
- minDwellSeconds: number().min(0).optional(),
17450
- /**
17451
- * Detection provenance filter. `any` (default / absent) matches every
17452
- * source; otherwise the subject's source must equal it. Legacy records
17453
- * with no stamped source are treated as `pipeline`. The union spans both
17454
- * record kinds — object events carry `pipeline` | `onboard`, synthetic
17455
- * tracks carry `sensor`.
17456
- */
17457
- source: _enum([
17458
- "pipeline",
17459
- "onboard",
17460
- "sensor",
17461
- "any"
17462
- ]).optional(),
17463
- /**
17464
- * Minimum identity / plate MATCH confidence in [0,1] — DISTINCT from the
17465
- * detector `minConfidence` (that gates the object-detection score; this
17466
- * gates the recognition/OCR match score). Fails when the subject carries
17467
- * no label-match confidence (never guess). TRACK-END only: the confidence
17468
- * lives on the recognition result and reaches the subject at track close.
17469
- *
17470
- * What it measures precisely (plumbed at track close — the closer threads
17471
- * the value into `NcTrackClosedInfo.labelConfidence`, the same seam as
17472
- * `importance`): the BEST recognition match confidence observed for the
17473
- * label the track carries at close — for a face, the peak cosine similarity
17474
- * of the ASSIGNED identity (`FaceMatch.score`, reset on an identity switch);
17475
- * for a plate, the peak OCR read score of the best-held plate
17476
- * (`plateText.confidence`). When BOTH a face and a plate were recognized on
17477
- * one track the higher of the two is used. A track that ended with no
17478
- * confident identity/plate match carries no value, so the condition fails
17479
- * closed for it (an un-recognized subject).
17480
- */
17481
- minLabelConfidence: number().min(0).max(1).optional(),
17482
- /**
17483
- * DEVICE-EVENT only. Raw device event-type tokens (`EventFire.eventType`,
17484
- * e.g. a doorbell `press` / `press_long`) — matched case-insensitively
17485
- * against the token carried on the device-event subject (extracted from the
17486
- * event-emitter runtime slice's `lastEvent.eventType`). Fails when the
17487
- * subject carries no token. Doorbell-pulse / passive-sensor kinds emit no
17488
- * eventType, so gate those with {@link sensorKinds} instead.
17489
- */
17490
- eventTypeTokens: array(string().min(1)).optional(),
17491
- /**
17492
- * DEVICE-EVENT only. Sensor/control taxonomy kinds (e.g. `doorbell`,
17493
- * `contact`, `button`, `device-event`) — matched against the persisted
17494
- * `SensorEvent.kind` (see `sensor-event-kinds.ts`). Membership is OR.
17495
- */
17496
- sensorKinds: array(string().min(1)).optional(),
17497
- /**
17498
- * PACKAGE-EVENT only. Which package phase fires the rule — `delivered`
17499
- * (a parked parcel appeared), `picked-up` (it departed), or `both`. Fails
17500
- * when the subject's phase does not match (a subject always carries a phase
17501
- * on the package-event trigger).
17502
- */
17503
- packagePhase: _enum([
17504
- "delivered",
17505
- "picked-up",
17506
- "both"
17507
- ]).optional(),
17508
- /**
17509
- * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
17510
- * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
17511
- * listed polygon (ZoneEngine membership semantics). Evaluated only when
17512
- * the subject carries a bbox; absent bbox ⇒ the condition FAILS.
17513
- */
17514
- customZones: array(MaskPolygonShapeSchema).optional(),
17515
- /**
17516
- * DEVICE-EVENT only. ZoneAnalytics occupancy edge — fires when a device's
17517
- * (optionally zone/class-scoped) occupancy count crosses the configured
17518
- * threshold and holds for `sustainSeconds`. Fail-closed on missing
17519
- * substrate (no snapshot / missing zone). See {@link NcOccupancyCondition}.
17520
- */
17521
- occupancy: NcOccupancyConditionSchema.optional()
17563
+ var BrokerKindSchema = _enum(["external", "embedded"]);
17564
+ /**
17565
+ * Broker live-probe status.
17566
+ *
17567
+ * - `connected` — last probe completed a clean CONNACK
17568
+ * - `disconnected` — no probe has run yet (cold cache)
17569
+ * - `auth-failed` — CONNACK refused with auth error (RC 4 / 5)
17570
+ * - `unreachable` — TCP connect timed out / refused
17571
+ * - `tls-error` — TLS handshake failed (cert / SNI / cipher)
17572
+ */
17573
+ var BrokerStatusSchema$1 = _enum([
17574
+ "connected",
17575
+ "disconnected",
17576
+ "auth-failed",
17577
+ "unreachable",
17578
+ "tls-error"
17579
+ ]);
17580
+ var BrokerInfoSchema = object({
17581
+ id: string(),
17582
+ name: string(),
17583
+ url: string(),
17584
+ kind: BrokerKindSchema,
17585
+ status: BrokerStatusSchema$1,
17586
+ latencyMs: number().nullable(),
17587
+ error: string().optional(),
17588
+ /** Embedded brokers only: number of MQTT clients currently connected. */
17589
+ connectedClients: number().int().nonnegative().optional(),
17590
+ /** Epoch ms of the last live probe (external) or aedes snapshot (embedded). */
17591
+ lastCheckedAt: number().optional()
17522
17592
  });
17523
- /** One delivery target: a `notification-output` Target ref + passthrough params. */
17524
- var NcRuleTargetSchema = object({
17525
- /** `notification-output` Target id. */
17526
- targetId: string().min(1),
17593
+ /**
17594
+ * Connection details — what a consumer needs to call
17595
+ * `mqtt.connect(url, options)`. We split URL + credentials so the
17596
+ * consumer can pass them as `mqtt.connect(url, { username, password })`
17597
+ * instead of stuffing creds into the URL (which leaks them into logs).
17598
+ */
17599
+ var BrokerConnectionDetailsSchema = object({
17600
+ url: string(),
17601
+ username: string().optional(),
17602
+ password: string().optional(),
17527
17603
  /**
17528
- * Per-backend passthrough. Recognized keys are mapped onto the canonical
17529
- * Notification (`priority`, `level`, `sound`, `clickUrl`, `ttl`); the
17530
- * degrade engine drops what the backend can't render.
17604
+ * Suggested prefix for `clientId`. Each consumer should suffix this
17605
+ * with its own discriminator (addon id, instance id) so reconnects
17606
+ * don't kick each other off (MQTT spec: clientId must be unique per
17607
+ * broker).
17531
17608
  */
17532
- params: record(string(), unknown()).optional()
17609
+ clientIdPrefix: string().optional()
17610
+ });
17611
+ var AddBrokerInputSchema = object({
17612
+ name: string().min(1),
17613
+ url: string().regex(/^(mqtt|mqtts|ws|wss):\/\//, "URL must start with mqtt(s):// or ws(s)://"),
17614
+ username: string().optional(),
17615
+ password: string().optional(),
17616
+ clientIdPrefix: string().optional()
17617
+ });
17618
+ var AddBrokerResultSchema = object({ id: string() });
17619
+ var IdInputSchema = object({ id: string() });
17620
+ var TestResultSchema$1 = discriminatedUnion("ok", [object({
17621
+ ok: literal(true),
17622
+ latencyMs: number()
17623
+ }), object({
17624
+ ok: literal(false),
17625
+ error: string()
17626
+ })]);
17627
+ var StartEmbeddedInputSchema = object({
17628
+ port: number().int().min(1).max(65535).default(1883),
17629
+ /** Allow anonymous connect (no username/password). Default: false. */
17630
+ allowAnonymous: boolean().default(false),
17631
+ /** Optional shared username/password for clients. */
17632
+ username: string().optional(),
17633
+ password: string().optional()
17634
+ });
17635
+ var StartEmbeddedResultSchema = object({
17636
+ id: string(),
17637
+ url: string()
17638
+ });
17639
+ var StatusSchema = object({
17640
+ brokerCount: number(),
17641
+ embeddedRunning: boolean()
17642
+ });
17643
+ 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);
17644
+ var NetworkEndpointSchema = object({
17645
+ url: string(),
17646
+ hostname: string(),
17647
+ port: number(),
17648
+ protocol: _enum(["http", "https"])
17649
+ });
17650
+ var NetworkAccessStatusSchema = object({
17651
+ connected: boolean(),
17652
+ endpoint: NetworkEndpointSchema.nullable(),
17653
+ error: string().optional()
17533
17654
  });
17534
17655
  /**
17535
- * Media attachment policy (P1 still-image subset).
17536
- * - `best` the best AVAILABLE subject image at dispatch time (D-3).
17537
- * - `best-matching` the media that explains WHY the rule fired: a rule
17538
- * matched on identities attaches the subject's `faceCrop`, one matched on
17539
- * plates attaches the `plateCrop`; a rule with no identity/plate condition
17540
- * (or when the specific crop is missing) degrades to `best`, then
17541
- * `keyFrame`, then no attachment — never delaying the send. The matched
17542
- * condition summary is frozen on the outbox row at enqueue (like the rule
17543
- * name), so the choice never drifts from the record that fired it.
17544
- * - `keyFrame` — the clean scene frame (no subject box).
17545
- * - `none` — no attachment.
17656
+ * Optional, richer endpoint shape returned by providers that expose
17657
+ * MORE than one ingress concurrently (Tailscale Ingress with mixed
17658
+ * serve+funnel rules, future ngrok multi-tunnel, …). Each entry carries
17659
+ * the originating provider config (mode + sourcePort) so the
17660
+ * orchestrator UI can label rows distinctly. Providers that expose only
17661
+ * one endpoint just omit `listEndpoints` from their provider impl.
17546
17662
  */
17547
- var NcMediaPolicySchema = object({ attach: _enum([
17548
- "best",
17549
- "best-matching",
17550
- "keyFrame",
17551
- "none"
17552
- ]).default("best") });
17553
- /** Throttle — cooldown survives restarts (rebuilt from the outbox on boot). */
17554
- var NcThrottleSchema = object({
17555
- cooldownSec: number().int().min(0).max(86400).default(60),
17556
- /** `rule` = one shared cooldown; `rule-device` = per-camera cooldown. */
17557
- scope: _enum(["rule", "rule-device"]).default("rule-device")
17558
- });
17559
- /** Client-supplied rule fields (server stamps id/createdBy/createdAt/updatedAt). */
17560
- var NcRuleInputSchema = object({
17561
- name: string().min(1).max(200),
17562
- enabled: boolean().default(true),
17563
- delivery: NcDeliverySchema,
17564
- conditions: NcConditionsSchema.default({}),
17565
- schedule: NcScheduleSchema.optional(),
17566
- targets: array(NcRuleTargetSchema).min(1),
17567
- media: NcMediaPolicySchema.default({ attach: "best" }),
17568
- throttle: NcThrottleSchema.default({
17569
- cooldownSec: 60,
17570
- scope: "rule-device"
17571
- }),
17572
- /** `{{var}}` templating over camera/class/label/zones/confidence/time. */
17573
- template: object({
17574
- title: string().max(500).optional(),
17575
- body: string().max(2e3).optional()
17576
- }).optional(),
17577
- /** Canonical notification priority ordinal (1..5); per-target overridable. */
17578
- priority: number().int().min(1).max(5).default(3),
17663
+ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
17579
17664
  /**
17580
- * Ownership/visibility key. Absent = admin/global rule (unchanged legacy
17581
- * behaviour, visible to all, read-only in the viewer). Present = personal
17582
- * rule owned by this userId. Server-stamped; never trusted from a client.
17665
+ * Stable id within the provider typically `<mode>-<sourcePort>` so
17666
+ * the orchestrator can dedupe across `listEndpoints` polls.
17583
17667
  */
17584
- ownerUserId: string().optional()
17668
+ id: string(),
17669
+ /** Operator-facing label (mirrors `MeshEndpoint.label`). */
17670
+ label: string(),
17671
+ /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
17672
+ mode: string().optional(),
17673
+ /** Originating local port the ingress fronts (informational). */
17674
+ sourcePort: number().optional()
17585
17675
  });
17676
+ method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
17586
17677
  /**
17587
- * Partial patch for `updateRule` any subset of the input fields, plus the
17588
- * persisted-only {@link NcRuleSchema} `disabledTargetIds` set. The latter is
17589
- * NOT a client-authored input field (it lives on the persisted rule, not the
17590
- * input), so it is added here explicitly to let the store's per-target opt-out
17591
- * toggle round-trip through the shared `update` path. Viewer opt-out mutations
17592
- * still flow through `nc.setRuleTargetEnabled` (owner-checked), never a raw
17593
- * `updateRule` patch.
17678
+ * notification-outputcanonical, capability-gated notification delivery.
17679
+ *
17680
+ * Apprise-derived model (see
17681
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
17682
+ * callers emit ONE canonical `Notification`; each provider declares a
17683
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
17684
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
17685
+ * message to what the kind supports — callers never special-case a service.
17686
+ *
17687
+ * DESIGN DECISIONS (locked):
17688
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
17689
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
17690
+ * cap. Rationale: the admin UI needs one uniform surface across the
17691
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
17692
+ * alternative would fork the UI per addon and cannot host the
17693
+ * discovery→adopt flow.
17694
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
17695
+ * the generated cap-mount auto-`concatCollection`-fans them across every
17696
+ * registered provider (notifiers addon + HA addon) so one catalog is
17697
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
17698
+ * `addonId` the generated collection router extracts from the call input.
17699
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
17700
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
17701
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
17702
+ * base64 fallback needed.
17703
+ *
17704
+ * TODO (deferred, closed-set change — separate decision): add
17705
+ * `providerKind: 'notify'` so notification providers surface on the unified
17706
+ * admin "Integrations" page.
17594
17707
  */
17595
- var NcRulePatchSchema = NcRuleInputSchema.partial().extend({ disabledTargetIds: array(string()).optional() });
17596
- /** A persisted rule. */
17597
- var NcRuleSchema = NcRuleInputSchema.extend({
17708
+ /**
17709
+ * Zentik-derived typed-media enum — the superset across every kind. Each
17710
+ * adapter picks what it supports and the degrade engine filters the rest.
17711
+ */
17712
+ var AttachmentMediaTypeSchema = _enum([
17713
+ "image",
17714
+ "video",
17715
+ "gif",
17716
+ "audio",
17717
+ "icon"
17718
+ ]);
17719
+ /**
17720
+ * A single attachment. Exactly one of `url` (remote source, most adapters
17721
+ * prefer this) or `bytes` (inline source; required for Pushover-style
17722
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
17723
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
17724
+ */
17725
+ var AttachmentSchema = object({
17726
+ mediaType: AttachmentMediaTypeSchema,
17727
+ url: string().optional(),
17728
+ bytes: _instanceof(Uint8Array).optional(),
17729
+ mime: string().optional(),
17730
+ name: string().optional()
17731
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
17732
+ var NotificationFormatSchema = _enum([
17733
+ "text",
17734
+ "markdown",
17735
+ "html"
17736
+ ]);
17737
+ /** A single tap-through action button. */
17738
+ var NotificationActionSchema = object({
17598
17739
  id: string(),
17599
- /** userId of the admin who created the rule (server-stamped caller). */
17600
- createdBy: string(),
17601
- createdAt: number(),
17602
- updatedAt: number(),
17603
- /**
17604
- * Per-target opt-out set. A targetId here is suppressed for THIS rule at
17605
- * send time. Only a target's OWNER may add/remove its id (server-checked
17606
- * in `nc.setRuleTargetEnabled`). Defaults to empty.
17607
- */
17608
- disabledTargetIds: array(string()).default([])
17609
- });
17610
- var NcTestResultSchema = object({
17611
- recordId: string(),
17612
- recordKind: _enum([
17613
- "object-event",
17614
- "track",
17615
- "device-event",
17616
- "package-event"
17617
- ]),
17618
- deviceId: number(),
17619
- timestamp: number(),
17620
- wouldFire: boolean(),
17621
- /** Condition id that failed (first failing group), when `wouldFire` is false. */
17622
- failedCondition: string().optional(),
17623
- className: string().optional(),
17624
- label: string().optional()
17740
+ label: string(),
17741
+ url: string().optional()
17625
17742
  });
17626
- var NcConditionDescriptorSchema = object({
17627
- /** Field id inside `NcConditions` (or `'schedule'` for the rule-level group). */
17743
+ /**
17744
+ * The canonical notification. `body` is the only hard field (Apprise model).
17745
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
17746
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
17747
+ * the adapter maps this ordinal onto its native level. `level?` is an
17748
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
17749
+ * `priority` for that one target.
17750
+ */
17751
+ var NotificationSchema = object({
17752
+ body: string(),
17753
+ title: string().optional(),
17754
+ format: NotificationFormatSchema.default("text"),
17755
+ priority: number().int().min(1).max(5).default(3),
17756
+ level: string().optional(),
17757
+ attachments: array(AttachmentSchema).optional(),
17758
+ clickUrl: string().optional(),
17759
+ actions: array(NotificationActionSchema).optional(),
17760
+ sound: string().optional(),
17761
+ ttl: number().optional(),
17762
+ tag: string().optional(),
17763
+ deviceId: number().optional(),
17764
+ eventId: string().optional(),
17765
+ metadata: record(string(), unknown()).optional()
17766
+ });
17767
+ /** One declared native severity/priority level for a kind. */
17768
+ var TargetKindLevelSchema = object({
17628
17769
  id: string(),
17629
- group: _enum([
17630
- "scope",
17631
- "class",
17632
- "zones",
17633
- "quality",
17634
- "label",
17635
- "schedule",
17636
- "device",
17637
- "package",
17638
- "occupancy"
17639
- ]),
17640
17770
  label: string(),
17641
- /** Editor widget the UI renders never hardcode per-condition forms. */
17642
- valueType: _enum([
17643
- "deviceIdList",
17644
- "stringList",
17645
- "number01",
17646
- "number",
17647
- "sourceSelect",
17648
- "zoneSelection",
17649
- "zoneIdList",
17650
- "schedule",
17651
- "plateMatcher",
17652
- "packagePhase",
17653
- "polygonDraw",
17654
- "occupancy"
17655
- ]),
17656
- operator: _enum([
17657
- "in",
17658
- "notIn",
17659
- "anyOf",
17660
- "allOf",
17661
- "gte",
17662
- "fuzzyIn",
17663
- "withinSchedule"
17664
- ]),
17665
- /** Which delivery kinds the condition applies to. */
17666
- appliesTo: array(NcDeliverySchema),
17667
- phase: string(),
17771
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
17772
+ ordinal: number().int().min(1).max(5).nullable(),
17773
+ flags: object({
17774
+ critical: boolean().optional(),
17775
+ silent: boolean().optional(),
17776
+ noPush: boolean().optional()
17777
+ }).optional(),
17778
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
17779
+ requires: array(string()).optional(),
17668
17780
  description: string().optional()
17669
17781
  });
17782
+ /** The full capability block consulted before dispatch. */
17783
+ var TargetKindCapsSchema = object({
17784
+ attachments: object({
17785
+ mediaTypes: array(AttachmentMediaTypeSchema),
17786
+ mode: _enum([
17787
+ "url",
17788
+ "bytes",
17789
+ "both"
17790
+ ]),
17791
+ max: number().int().nonnegative(),
17792
+ maxBytes: number().int().positive().optional()
17793
+ }),
17794
+ /** Max action buttons (0 = none). */
17795
+ actions: number().int().nonnegative(),
17796
+ levels: array(TargetKindLevelSchema),
17797
+ format: array(NotificationFormatSchema),
17798
+ clickUrl: boolean(),
17799
+ sound: boolean(),
17800
+ ttl: boolean(),
17801
+ bodyMaxLen: number().int().positive()
17802
+ });
17670
17803
  /**
17671
- * The delivery lifecycle status of a history row a straight read of the
17672
- * durable outbox row's own status (single source of truth):
17673
- * - `pending` — enqueued, in-flight or retrying with backoff
17674
- * - `sent` — delivered (terminal)
17675
- * - `dead` dead-lettered after exhausting retries / a permanent
17676
- * backend rejection / a deleted target (terminal; carries
17677
- * the failure `error`)
17678
- *
17679
- * P1 has no `suppressed-quiet-hours` / `snoozed` states — those ride the P2
17680
- * user dimension (quiet hours / snooze) and are additive when they land.
17804
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
17805
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
17806
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`)
17807
+ * the union is large and not meant for runtime validation here; the exported
17808
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
17681
17809
  */
17682
- var NcHistoryStatusSchema = _enum([
17683
- "pending",
17684
- "sent",
17685
- "dead"
17686
- ]);
17687
- /** The evaluated record kind a history row descends from (one per trigger). */
17688
- var NcHistoryRecordKindSchema = _enum([
17689
- "object-event",
17690
- "track-end",
17691
- "device-event",
17692
- "package-event"
17693
- ]);
17694
- /** Subject summary frozen on the row at fire time (survives rule/record edits). */
17695
- var NcHistorySubjectSchema = object({
17696
- className: string(),
17697
- label: string().optional(),
17698
- confidence: number().optional(),
17699
- zones: array(string()),
17700
- timestamp: number()
17810
+ var ConfigSchemaPassthrough = unknown();
17811
+ var TargetKindSchema = object({
17812
+ kind: string(),
17813
+ label: string(),
17814
+ icon: string(),
17815
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
17816
+ addonId: string(),
17817
+ configSchema: ConfigSchemaPassthrough,
17818
+ supportsDiscovery: boolean(),
17819
+ caps: TargetKindCapsSchema
17701
17820
  });
17702
17821
  /**
17703
- * One delivery-history row. This is a read-only VIEW over the durable
17704
- * outbox row (single source of truth the same row the drain loop drives;
17705
- * NO second write path, so history can never drift from delivery state).
17706
- * The §3.2 fields map directly: `ruleId`/`targetId`/`deviceId` are columns,
17707
- * `eventRef` is `recordKind`+`recordId`, `timestamps` are `createdAt`
17708
- * (fire) / `updatedAt` (last transition), `status` + `error` are the
17709
- * lifecycle. `ruleName` + `subject` are the intent snapshot frozen at
17710
- * enqueue. `userId?` (per-recipient history) is P2 — no user dimension in
17711
- * P1 (admin scope only).
17822
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
17823
+ * (return a presence marker only) when serving `listTargets` never
17824
+ * round-trip a stored secret to the UI.
17712
17825
  */
17713
- var NcHistoryEntrySchema = object({
17714
- /** Outbox row id — the stable dedup id `ruleId:dedupRef:targetId`. */
17826
+ var TargetSchema = object({
17715
17827
  id: string(),
17716
- ruleId: string(),
17717
- /** Rule name frozen at fire time (outlives a later rename / delete). */
17718
- ruleName: string(),
17719
- /** The rule urgency/trigger that produced this delivery. */
17720
- delivery: NcDeliverySchema,
17721
- targetId: string(),
17722
- deviceId: number(),
17723
- recordKind: NcHistoryRecordKindSchema,
17724
- /** Event / track ref of the evaluated record (§3.2 `eventRef`). */
17725
- recordId: string(),
17726
- /** Present for track-scoped deliveries (object-event / track-end). */
17727
- trackId: string().optional(),
17728
- status: NcHistoryStatusSchema,
17729
- /** Delivery attempts made so far. */
17730
- attempts: number().int(),
17731
- /** Fire time (outbox enqueue). */
17732
- createdAt: number(),
17733
- /** Last transition time (terminal for sent / dead). */
17734
- updatedAt: number(),
17735
- /** Failure detail — present on a `dead` row. */
17736
- error: string().optional(),
17737
- subject: NcHistorySubjectSchema
17828
+ name: string(),
17829
+ kind: string(),
17830
+ addonId: string(),
17831
+ enabled: boolean(),
17832
+ config: record(string(), unknown())
17738
17833
  });
17739
- /**
17740
- * Query filter for `getHistory` (spec §4.2). Every field is a narrowing
17741
- * AND; absent = unbounded on that axis. `since`/`until` bound the fire time
17742
- * (`createdAt`, epoch ms, inclusive). `limit` is clamped to
17743
- * {@link NC_HISTORY_LIMIT_MAX}. `userId` (per-recipient filtering) is P2.
17744
- */
17745
- var NcHistoryFilterSchema = object({
17746
- ruleId: string().optional(),
17747
- deviceId: number().optional(),
17748
- status: NcHistoryStatusSchema.optional(),
17749
- since: number().optional(),
17750
- until: number().optional(),
17751
- limit: number().int().min(1).max(500).default(100)
17834
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
17835
+ var DiscoveredTargetSchema = object({
17836
+ kind: string(),
17837
+ suggestedName: string(),
17838
+ config: record(string(), unknown())
17752
17839
  });
17753
- 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 }), {
17754
- kind: "mutation",
17755
- auth: "admin",
17756
- caller: "required"
17757
- }), method(object({
17758
- ruleId: string(),
17759
- patch: NcRulePatchSchema
17760
- }), object({ rule: NcRuleSchema }), {
17761
- kind: "mutation",
17762
- auth: "admin",
17763
- caller: "required"
17764
- }), method(object({ ruleId: string() }), object({ success: literal(true) }), {
17765
- kind: "mutation",
17766
- auth: "admin"
17767
- }), method(object({
17768
- ruleId: string(),
17840
+ /** The degrade engine's report what was resolved / dropped / degraded. */
17841
+ var RenderedAsSchema = object({
17842
+ level: string(),
17843
+ format: NotificationFormatSchema,
17844
+ attachmentsSent: number().int().nonnegative(),
17845
+ actionsSent: number().int().nonnegative(),
17846
+ truncated: boolean(),
17847
+ dropped: array(string())
17848
+ });
17849
+ var SendResultSchema = object({
17850
+ success: boolean(),
17851
+ error: string().optional(),
17852
+ renderedAs: RenderedAsSchema.optional()
17853
+ });
17854
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
17855
+ var TestResultSchema = SendResultSchema;
17856
+ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
17857
+ kind: string(),
17858
+ config: record(string(), unknown()).optional()
17859
+ }), array(DiscoveredTargetSchema)), method(object({
17860
+ targetId: string(),
17861
+ notification: NotificationSchema
17862
+ }), SendResultSchema, { kind: "mutation" }), method(object({
17863
+ targetId: string(),
17864
+ sample: NotificationSchema.optional()
17865
+ }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
17866
+ targetId: string(),
17769
17867
  enabled: boolean()
17770
- }), object({ success: literal(true) }), {
17771
- kind: "mutation",
17772
- auth: "admin"
17773
- }), method(object({
17774
- rule: NcRuleInputSchema,
17775
- lookbackMinutes: number().int().min(1).max(1440).default(60)
17776
- }), object({ results: array(NcTestResultSchema) }), {
17777
- kind: "mutation",
17778
- auth: "admin"
17779
- }), method(object({}), object({ catalog: array(NcConditionDescriptorSchema) })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
17868
+ }), _void(), { kind: "mutation" });
17780
17869
  /**
17781
17870
  * Zod schemas for persisted record types.
17782
17871
  *