@camstack/addon-provider-homeassistant 1.2.11 → 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 (3) hide show
  1. package/dist/addon.js +828 -287
  2. package/dist/addon.mjs +828 -287
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -9467,8 +9467,11 @@ function prepareNotification(caps, n) {
9467
9467
  });
9468
9468
  }
9469
9469
  const inActions = n.actions ?? [];
9470
- const actions = inActions.slice(0, Math.max(0, caps.actions));
9471
- if (inActions.length > actions.length) dropped.push("actions");
9470
+ const kept = inActions.slice(0, Math.max(0, caps.actions));
9471
+ if (inActions.length > kept.length) dropped.push("actions");
9472
+ const iconsSupported = caps.actionIcons === true;
9473
+ if (!iconsSupported && kept.some((a) => a.icon !== void 0)) dropped.push("actionIcons");
9474
+ const actions = iconsSupported ? kept : kept.map(({ icon: _icon, ...rest }) => rest);
9472
9475
  let clickUrl = null;
9473
9476
  if (n.clickUrl !== void 0) if (caps.clickUrl) clickUrl = n.clickUrl;
9474
9477
  else dropped.push("clickUrl");
@@ -9567,6 +9570,290 @@ var MaskGridDimsSchema = object({
9567
9570
  height: number()
9568
9571
  });
9569
9572
  /**
9573
+ * notification-output — canonical, capability-gated notification delivery.
9574
+ *
9575
+ * Apprise-derived model (see
9576
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
9577
+ * callers emit ONE canonical `Notification`; each provider declares a
9578
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
9579
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
9580
+ * message to what the kind supports — callers never special-case a service.
9581
+ *
9582
+ * DESIGN DECISIONS (locked):
9583
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
9584
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
9585
+ * cap. Rationale: the admin UI needs one uniform surface across the
9586
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
9587
+ * alternative would fork the UI per addon and cannot host the
9588
+ * discovery→adopt flow.
9589
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
9590
+ * the generated cap-mount auto-`concatCollection`-fans them across every
9591
+ * registered provider (notifiers addon + HA addon) so one catalog is
9592
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
9593
+ * `addonId` the generated collection router extracts from the call input.
9594
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
9595
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
9596
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
9597
+ * base64 fallback needed.
9598
+ *
9599
+ * TODO (deferred, closed-set change — separate decision): add
9600
+ * `providerKind: 'notify'` so notification providers surface on the unified
9601
+ * admin "Integrations" page.
9602
+ */
9603
+ /**
9604
+ * Zentik-derived typed-media enum — the superset across every kind. Each
9605
+ * adapter picks what it supports and the degrade engine filters the rest.
9606
+ */
9607
+ var AttachmentMediaTypeSchema = _enum([
9608
+ "image",
9609
+ "video",
9610
+ "gif",
9611
+ "audio",
9612
+ "icon"
9613
+ ]);
9614
+ /**
9615
+ * A single attachment. Exactly one of `url` (remote source, most adapters
9616
+ * prefer this) or `bytes` (inline source; required for Pushover-style
9617
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
9618
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
9619
+ */
9620
+ var AttachmentSchema = object({
9621
+ mediaType: AttachmentMediaTypeSchema,
9622
+ url: string().optional(),
9623
+ bytes: _instanceof(Uint8Array).optional(),
9624
+ mime: string().optional(),
9625
+ name: string().optional()
9626
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
9627
+ var NotificationFormatSchema = _enum([
9628
+ "text",
9629
+ "markdown",
9630
+ "html"
9631
+ ]);
9632
+ /**
9633
+ * The CLOSED icon vocabulary an action button may use.
9634
+ *
9635
+ * A closed set, not a free string, and that is the whole point: an arbitrary
9636
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
9637
+ * notices — the same class of gap as a zone vocabulary nothing produced
9638
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
9639
+ * declares `actionIcons: false` and the degrade engine strips the field.
9640
+ *
9641
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
9642
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
9643
+ * differently.
9644
+ */
9645
+ var NotificationActionIconSchema = _enum([
9646
+ "acknowledge",
9647
+ "dismiss",
9648
+ "silence",
9649
+ "view",
9650
+ "play",
9651
+ "open",
9652
+ "close",
9653
+ "lock",
9654
+ "unlock",
9655
+ "arm",
9656
+ "disarm",
9657
+ "light",
9658
+ "alert"
9659
+ ]);
9660
+ /** A single tap-through action button. */
9661
+ var NotificationActionSchema = object({
9662
+ id: string(),
9663
+ label: string(),
9664
+ url: string().optional(),
9665
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
9666
+ icon: NotificationActionIconSchema.optional(),
9667
+ /**
9668
+ * Renders in a warning style where the notifier supports it.
9669
+ *
9670
+ * A HINT, never a gate. The callback's authority is its token and nothing
9671
+ * else — see `notification-center/action-token.ts` for what that does and
9672
+ * does not buy.
9673
+ */
9674
+ destructive: boolean().optional()
9675
+ });
9676
+ /**
9677
+ * The canonical notification. `body` is the only hard field (Apprise model).
9678
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
9679
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
9680
+ * the adapter maps this ordinal onto its native level. `level?` is an
9681
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
9682
+ * `priority` for that one target.
9683
+ */
9684
+ var NotificationSchema = object({
9685
+ body: string(),
9686
+ title: string().optional(),
9687
+ format: NotificationFormatSchema.default("text"),
9688
+ priority: number().int().min(1).max(5).default(3),
9689
+ level: string().optional(),
9690
+ attachments: array(AttachmentSchema).optional(),
9691
+ clickUrl: string().optional(),
9692
+ actions: array(NotificationActionSchema).optional(),
9693
+ sound: string().optional(),
9694
+ ttl: number().optional(),
9695
+ tag: string().optional(),
9696
+ deviceId: number().optional(),
9697
+ eventId: string().optional(),
9698
+ metadata: record(string(), unknown()).optional()
9699
+ });
9700
+ /** One declared native severity/priority level for a kind. */
9701
+ var TargetKindLevelSchema = object({
9702
+ id: string(),
9703
+ label: string(),
9704
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
9705
+ ordinal: number().int().min(1).max(5).nullable(),
9706
+ flags: object({
9707
+ critical: boolean().optional(),
9708
+ silent: boolean().optional(),
9709
+ noPush: boolean().optional()
9710
+ }).optional(),
9711
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
9712
+ requires: array(string()).optional(),
9713
+ description: string().optional()
9714
+ });
9715
+ /** The full capability block consulted before dispatch. */
9716
+ var TargetKindCapsSchema = object({
9717
+ attachments: object({
9718
+ mediaTypes: array(AttachmentMediaTypeSchema),
9719
+ mode: _enum([
9720
+ "url",
9721
+ "bytes",
9722
+ "both"
9723
+ ]),
9724
+ max: number().int().nonnegative(),
9725
+ maxBytes: number().int().positive().optional()
9726
+ }),
9727
+ /** Max action buttons (0 = none). */
9728
+ actions: number().int().nonnegative(),
9729
+ /**
9730
+ * Whether this kind renders a per-action ICON.
9731
+ *
9732
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
9733
+ * run on the addon cap path — three production failures in one day taught
9734
+ * this repo that once. Absent is read as false by the degrade engine, which
9735
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
9736
+ * assumed and dropped costs the operator's trust in the field.
9737
+ */
9738
+ actionIcons: boolean().optional(),
9739
+ levels: array(TargetKindLevelSchema),
9740
+ format: array(NotificationFormatSchema),
9741
+ clickUrl: boolean(),
9742
+ sound: boolean(),
9743
+ ttl: boolean(),
9744
+ bodyMaxLen: number().int().positive()
9745
+ });
9746
+ /**
9747
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
9748
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
9749
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
9750
+ * the union is large and not meant for runtime validation here; the exported
9751
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
9752
+ */
9753
+ var ConfigSchemaPassthrough$1 = unknown();
9754
+ var TargetKindSchema = object({
9755
+ kind: string(),
9756
+ label: string(),
9757
+ icon: string(),
9758
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
9759
+ addonId: string(),
9760
+ /**
9761
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
9762
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
9763
+ * when the addon bundles no icon for that kind — the client then falls back
9764
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
9765
+ *
9766
+ * Root-relative on purpose: it resolves against whatever origin serves a web
9767
+ * client, and a native client joins it onto its own hub base.
9768
+ *
9769
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
9770
+ * field that survived only because the runtime cap-router forwards provider
9771
+ * output verbatim — so every consumer had to re-declare it by hand to stop
9772
+ * its own Zod parse from stripping it, and the whole arrangement would have
9773
+ * broken silently the moment output validation was tightened anywhere.
9774
+ */
9775
+ iconUrl: string().optional(),
9776
+ /**
9777
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
9778
+ *
9779
+ * The server knows this and therefore says it, because the client cannot
9780
+ * safely guess: a React-Native client renders SVG and raster through two
9781
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
9782
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
9783
+ * placeholder glyph for every vector icon while the web build looked fine.
9784
+ *
9785
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
9786
+ * not been updated — a client that cannot determine the type should prefer
9787
+ * its raster path, which is the safe default for an unknown image.
9788
+ */
9789
+ iconMediaType: string().optional(),
9790
+ configSchema: ConfigSchemaPassthrough$1,
9791
+ supportsDiscovery: boolean(),
9792
+ caps: TargetKindCapsSchema
9793
+ });
9794
+ /**
9795
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
9796
+ * (return a presence marker only) when serving `listTargets` — never
9797
+ * round-trip a stored secret to the UI.
9798
+ */
9799
+ var TargetSchema = object({
9800
+ id: string(),
9801
+ name: string(),
9802
+ kind: string(),
9803
+ addonId: string(),
9804
+ enabled: boolean(),
9805
+ config: record(string(), unknown())
9806
+ });
9807
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
9808
+ var DiscoveredTargetSchema = object({
9809
+ kind: string(),
9810
+ suggestedName: string(),
9811
+ config: record(string(), unknown())
9812
+ });
9813
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
9814
+ var RenderedAsSchema = object({
9815
+ level: string(),
9816
+ format: NotificationFormatSchema,
9817
+ attachmentsSent: number().int().nonnegative(),
9818
+ actionsSent: number().int().nonnegative(),
9819
+ truncated: boolean(),
9820
+ dropped: array(string())
9821
+ });
9822
+ var SendResultSchema = object({
9823
+ success: boolean(),
9824
+ error: string().optional(),
9825
+ renderedAs: RenderedAsSchema.optional()
9826
+ });
9827
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
9828
+ var TestResultSchema = SendResultSchema;
9829
+ var notificationOutputCapability = {
9830
+ name: "notification-output",
9831
+ scope: "system",
9832
+ mode: "collection",
9833
+ methods: {
9834
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
9835
+ listTargets: method(object({}), array(TargetSchema)),
9836
+ discoverTargets: method(object({
9837
+ kind: string(),
9838
+ config: record(string(), unknown()).optional()
9839
+ }), array(DiscoveredTargetSchema)),
9840
+ send: method(object({
9841
+ targetId: string(),
9842
+ notification: NotificationSchema
9843
+ }), SendResultSchema, { kind: "mutation" }),
9844
+ testTarget: method(object({
9845
+ targetId: string(),
9846
+ sample: NotificationSchema.optional()
9847
+ }), TestResultSchema, { kind: "mutation" }),
9848
+ upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
9849
+ deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
9850
+ setTargetEnabled: method(object({
9851
+ targetId: string(),
9852
+ enabled: boolean()
9853
+ }), _void(), { kind: "mutation" })
9854
+ }
9855
+ };
9856
+ /**
9570
9857
  * notification-rules — the Notification Center rule surface (P1 core).
9571
9858
  *
9572
9859
  * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
@@ -9696,7 +9983,115 @@ var NcZoneConditionSchema = object({
9696
9983
  * The P1 condition set — a flat AND of groups; absent group = pass;
9697
9984
  * membership lists are OR within the list (spec §2.3).
9698
9985
  */
9986
+ /**
9987
+ * What a rule may actuate.
9988
+ *
9989
+ * **No hand-maintained allowlist** (operator decision, and the right one — a
9990
+ * written list of methods is a third parallel map to keep aligned, and this
9991
+ * repo has paid for those). The boundary instead comes from a property the
9992
+ * capabilities already carry: an action may target only a **device-scoped**
9993
+ * capability method.
9994
+ *
9995
+ * That is not decoration. A rule can be authored by a NON-ADMIN — personal
9996
+ * rules are a supported flow — and the executor runs with the addon's
9997
+ * privileges, so an unbounded action is an arbitrary RPC channel with a
9998
+ * privilege escalation attached. Restricting to device scope excludes the
9999
+ * system caps (`device-manager.removeDevice` and friends) by construction,
10000
+ * costs nothing to maintain, and cannot rot: a cap that stops being
10001
+ * device-scoped stops being actuatable in the same change.
10002
+ *
10003
+ * The executor enforces it; {@link NcRuleActionSchema} carries the intent.
10004
+ */
10005
+ /**
10006
+ * One step of a sequence.
10007
+ *
10008
+ * `wait` is a first-class step rather than a property of the next action: it is
10009
+ * what makes a sequence a SEQUENCE and not a list — "unlock, wait 5s, open"
10010
+ * cannot be expressed otherwise.
10011
+ */
10012
+ var NcRuleActionSchema = discriminatedUnion("kind", [object({
10013
+ kind: literal("wait"),
10014
+ seconds: number().min(0).max(300)
10015
+ }), object({
10016
+ kind: literal("cap"),
10017
+ deviceId: number().int(),
10018
+ /** Capability name, e.g. `alarm-panel`. */
10019
+ cap: string().min(1),
10020
+ /** Method on it. The executor refuses a non-device-scoped cap. */
10021
+ method: string().min(1),
10022
+ /** Method arguments, minus `deviceId` (the executor injects it). */
10023
+ args: record(string(), unknown()).optional()
10024
+ })]);
10025
+ /**
10026
+ * A named, ordered run of steps with its own throttle.
10027
+ *
10028
+ * `minDelaySec` exists because a noisy rule otherwise hammers a physical
10029
+ * actuator — the rule's own cooldown governs NOTIFICATIONS, which is a
10030
+ * different budget from "how often may this gate actually open".
10031
+ */
10032
+ var NcRuleActionSequenceSchema = object({
10033
+ name: string().min(1).max(120),
10034
+ enabled: boolean(),
10035
+ minDelaySec: number().int().min(0).max(86400).optional(),
10036
+ actions: array(NcRuleActionSchema).min(1)
10037
+ });
10038
+ /**
10039
+ * One button carried by the notification, running a named sequence on tap.
10040
+ *
10041
+ * **Read this before adding a button that does something physical.** The tap
10042
+ * arrives over a link that travelled through third-party infrastructure — ntfy,
10043
+ * a push relay, whatever forwarded the message — and the callback's ONLY
10044
+ * authority is the token in that link: single-use, short-lived, bound to this
10045
+ * one action of this one notification. It does not identify who tapped.
10046
+ * Whoever holds the notification can run the button, once, inside the window.
10047
+ * That is the operator's explicit choice (2026-08-05), and `destructive` is a
10048
+ * rendering hint, not a second gate. [D47](decisions/adr-0047.md).
10049
+ */
10050
+ var NcRuleNotificationButtonSchema = object({
10051
+ /** Stable id — travels in the callback and identifies the button in logs. */
10052
+ id: string().min(1).max(64),
10053
+ label: string().min(1).max(40),
10054
+ /** Name of a sequence in `onTrigger`. The dispatcher drops a button whose
10055
+ * sequence does not exist rather than minting a token for nothing. */
10056
+ sequence: string().min(1).max(120),
10057
+ icon: NotificationActionIconSchema.optional(),
10058
+ destructive: boolean().optional()
10059
+ });
10060
+ /**
10061
+ * Sequences a rule runs, by hook point.
10062
+ *
10063
+ * ONLY `onTrigger` is here, deliberately. The reference also has activation /
10064
+ * deactivation / reset / post-generation hooks, and they are wanted — but this
10065
+ * repo's expensive failure mode is declaring a surface nothing produces, so a
10066
+ * hook appears here in the same change that produces its edge, never before.
10067
+ */
10068
+ var NcRuleActionsSchema = object({
10069
+ /** Runs when the rule MATCHES. */
10070
+ onTrigger: array(NcRuleActionSequenceSchema).optional(),
10071
+ /**
10072
+ * Buttons the NOTIFICATION carries, each running one of this rule's
10073
+ * sequences when tapped.
10074
+ *
10075
+ * Deliberately a REFERENCE to a sequence rather than a second place to
10076
+ * author steps. A button that could define its own actions would be a
10077
+ * parallel actuation vocabulary — the executor's device-scope check, the
10078
+ * stop-at-first-failure rule and the per-sequence throttle all live on
10079
+ * sequences, and a second authoring surface would drift from every one of
10080
+ * them.
10081
+ *
10082
+ * A sequence reachable ONLY by a button simply appears in `onTrigger` with
10083
+ * `enabled: false`: it is then authored, throttled and validated like the
10084
+ * rest, and nothing runs it automatically.
10085
+ */
10086
+ buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
10087
+ });
9699
10088
  var NcConditionsSchema = object({
10089
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
10090
+ deviceState: object({
10091
+ deviceId: number().int(),
10092
+ /** Any of these matches. */
10093
+ states: array(string().min(1)).min(1)
10094
+ }).optional(),
9700
10095
  /** Device scope — absent = all devices. */
9701
10096
  devices: array(number()).optional(),
9702
10097
  /** Detector class names (any overlap with the record's class set). */
@@ -9897,6 +10292,14 @@ var NcMediaPolicySchema = object({
9897
10292
  clipPreRollSec: number().int().min(0).max(30).optional(),
9898
10293
  clipPostRollSec: number().int().min(0).max(30).optional(),
9899
10294
  /**
10295
+ * Playback rate of the attached gif / clip. Absent = 2x.
10296
+ *
10297
+ * A notification clip is GLANCED at on a lock screen, not watched: at real
10298
+ * time an eight-second passage is eight seconds of the recipient's attention
10299
+ * and twice the bytes. 1 is real time for the operator who wants it.
10300
+ */
10301
+ clipSpeed: number().min(1).max(8).optional(),
10302
+ /**
9900
10303
  * Which stream profile the footage is cut from. Absent = the CHEAPEST
9901
10304
  * assigned profile: a notification is watched on a phone, so the 4K
9902
10305
  * rendition would burn CPU to produce a file the client downscales anyway.
@@ -9968,7 +10371,30 @@ var NcRuleInputSchema = object({
9968
10371
  * behaviour, visible to all, read-only in the viewer). Present = personal
9969
10372
  * rule owned by this userId. Server-stamped; never trusted from a client.
9970
10373
  */
9971
- ownerUserId: string().optional()
10374
+ ownerUserId: string().optional(),
10375
+ /**
10376
+ * May a non-admin snooze this rule for EVERYONE, not just themselves?
10377
+ *
10378
+ * A snooze is personal by default — it silences the person who set it. This
10379
+ * opts THIS rule into the "the gardener is here all afternoon" case, where
10380
+ * silencing the camera for the whole household is legitimate. It silences
10381
+ * other people, so it is off unless a rule deliberately allows it.
10382
+ *
10383
+ * `.optional()`, deliberately NOT `.default()`: a Zod default does not run on
10384
+ * the addon cap path (three production failures in one day), so absent is
10385
+ * read as `false` by {@link canSetGlobal} in the engine. Admins are not bound
10386
+ * by this flag — see the scope rules on that function.
10387
+ */
10388
+ snoozeAllowGlobal: boolean().optional(),
10389
+ /**
10390
+ * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
10391
+ *
10392
+ * This is what makes the rule set the alarm's trigger set without the alarm
10393
+ * being a special case: arming is
10394
+ * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
10395
+ * shape as every other actuation.
10396
+ */
10397
+ actions: NcRuleActionsSchema.optional()
9972
10398
  });
9973
10399
  /**
9974
10400
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -10039,7 +10465,8 @@ var NcConditionDescriptorSchema = object({
10039
10465
  "packagePhase",
10040
10466
  "crossingSelect",
10041
10467
  "polygonDraw",
10042
- "occupancy"
10468
+ "occupancy",
10469
+ "deviceState"
10043
10470
  ]),
10044
10471
  operator: _enum([
10045
10472
  "in",
@@ -10053,7 +10480,28 @@ var NcConditionDescriptorSchema = object({
10053
10480
  /** Which delivery kinds the condition applies to. */
10054
10481
  appliesTo: array(NcDeliverySchema),
10055
10482
  phase: string(),
10056
- description: string().optional()
10483
+ description: string().optional(),
10484
+ /**
10485
+ * The CHOICES for a single-choice widget (`sourceSelect`, `crossingSelect`,
10486
+ * `packagePhase`, …), served with the descriptor.
10487
+ *
10488
+ * Before this the descriptor said which widget to render and not what to put
10489
+ * in it, so every option list lived in three places: this file's enums, the
10490
+ * admin's `NC_*_OPTIONS` and the viewer's `NC_*_VALUES`. That triple mirror
10491
+ * is the drift that emptied the viewer's rule editor on 2026-08-04 — the app
10492
+ * mirrors the cap by hand, so it is only ever as current as its last build.
10493
+ *
10494
+ * With the options on the wire, a condition of an EXISTING `valueType` costs
10495
+ * zero client changes. Clients keep a local fallback for an older hub that
10496
+ * does not send them; absent here is "use your own list", not "no choices".
10497
+ */
10498
+ options: array(object({
10499
+ /** Written to the rule verbatim. `''` means the ABSENT state. */
10500
+ value: string(),
10501
+ label: string(),
10502
+ /** What THIS choice matches — shown one at a time, under the control. */
10503
+ hint: string().optional()
10504
+ })).readonly().optional()
10057
10505
  });
10058
10506
  /**
10059
10507
  * The delivery lifecycle status of a history row — a straight read of the
@@ -10138,6 +10586,74 @@ var NcHistoryFilterSchema = object({
10138
10586
  until: number().optional(),
10139
10587
  limit: number().int().min(1).max(500).default(100)
10140
10588
  });
10589
+ /**
10590
+ * What a snooze covers. Broader scopes win when several overlap, so one window
10591
+ * leaves ONE digest rather than a rule snooze and a whole-feed snooze both
10592
+ * summarising the same silence.
10593
+ */
10594
+ var NcSnoozeScopeSchema = _enum([
10595
+ "rule",
10596
+ "device",
10597
+ "all"
10598
+ ]);
10599
+ /**
10600
+ * Client-authored snooze. The server stamps `userId`, `startedAt` and
10601
+ * `expiresAt` — a DURATION is sent rather than an instant so a client with a
10602
+ * skewed clock cannot author a window that is already over, or never ends.
10603
+ */
10604
+ var NcSnoozeInputSchema = object({
10605
+ scope: NcSnoozeScopeSchema,
10606
+ /** Required when `scope: 'rule'` — a scoped snooze with no id matches
10607
+ * NOTHING rather than degrading to "everything". */
10608
+ ruleId: string().optional(),
10609
+ /** Required when `scope: 'device'`. */
10610
+ deviceId: number().int().optional(),
10611
+ durationMinutes: number().int().min(1).max(1440),
10612
+ /**
10613
+ * Silence this for EVERY recipient, not just the caller. Permission is
10614
+ * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
10615
+ * broader scopes). Absent = personal.
10616
+ */
10617
+ global: boolean().optional(),
10618
+ /**
10619
+ * Deliver a summary of what was suppressed when the window ends. Absent =
10620
+ * ON: someone silencing a nuisance camera wants it off, someone silencing a
10621
+ * SECURITY camera wants to know what they missed, and choosing "off" for
10622
+ * everybody is how a snooze becomes an outage. Resolved to a concrete
10623
+ * boolean by the server at create time — never left to a Zod default, which
10624
+ * does not run on the addon cap path.
10625
+ */
10626
+ summary: boolean().optional()
10627
+ });
10628
+ /** A persisted snooze window. */
10629
+ var NcSnoozeSchema = object({
10630
+ id: string(),
10631
+ /** Who set it. Also who it silences, unless `global`. */
10632
+ userId: string(),
10633
+ scope: NcSnoozeScopeSchema,
10634
+ ruleId: string().optional(),
10635
+ deviceId: number().int().optional(),
10636
+ startedAt: number(),
10637
+ /** Exclusive: at exactly this instant the snooze is over. Expiry is a
10638
+ * COMPARISON, not a job — no sweeper can leave the operator silenced. */
10639
+ expiresAt: number(),
10640
+ global: boolean(),
10641
+ summary: boolean(),
10642
+ /** When the end-of-window digest went out. Absent = not sent (yet, or the
10643
+ * window has not closed, or `summary` is false). */
10644
+ digestSentAt: number().optional()
10645
+ });
10646
+ object({
10647
+ snoozeId: string(),
10648
+ targetId: string(),
10649
+ ruleId: string(),
10650
+ ruleName: string(),
10651
+ deviceId: number().int(),
10652
+ /** How many notifications this snooze hid for that pair. */
10653
+ count: number().int(),
10654
+ firstAt: number(),
10655
+ lastAt: number()
10656
+ });
10141
10657
  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 }), {
10142
10658
  kind: "mutation",
10143
10659
  auth: "admin",
@@ -10167,7 +10683,13 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
10167
10683
  }), method(object({}), object({
10168
10684
  catalog: array(NcConditionDescriptorSchema),
10169
10685
  taxonomy: NcTaxonomySchema.optional()
10170
- })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
10686
+ })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" }), method(object({}), object({ snoozes: array(NcSnoozeSchema) }), { caller: "required" }), method(object({ snooze: NcSnoozeInputSchema }), object({ snooze: NcSnoozeSchema }), {
10687
+ kind: "mutation",
10688
+ caller: "required"
10689
+ }), method(object({ snoozeId: string() }), object({ success: literal(true) }), {
10690
+ kind: "mutation",
10691
+ caller: "required"
10692
+ });
10171
10693
  /**
10172
10694
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
10173
10695
  *
@@ -11187,7 +11709,15 @@ method(object({
11187
11709
  format: _enum(["gif", "mp4"]).default("gif"),
11188
11710
  maxWidth: number().int().min(120).max(1920).default(480),
11189
11711
  /** GIF only — MP4 keeps the source cadence. */
11190
- fps: number().int().min(1).max(15).default(5)
11712
+ fps: number().int().min(1).max(15).default(5),
11713
+ /**
11714
+ * Playback rate. A notification clip is GLANCED at on a lock screen,
11715
+ * not watched, so 2x is the default: the recipient sees the whole
11716
+ * passage in half the time and the GIF is half the bytes. `1` is real
11717
+ * time. Applies to MP4 as well — the operator set a speed, not a GIF
11718
+ * speed.
11719
+ */
11720
+ speed: number().min(1).max(8).default(2)
11191
11721
  }), object({
11192
11722
  base64: string(),
11193
11723
  mime: string(),
@@ -19424,7 +19954,20 @@ method(object({
19424
19954
  }), _void(), {
19425
19955
  kind: "mutation",
19426
19956
  auth: "admin"
19427
- }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({ addonId: string().optional() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
19957
+ }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({
19958
+ addonId: string().optional(),
19959
+ /**
19960
+ * `slim` omits `config` (returned `{}`), `metadata` (null) and the
19961
+ * `sourceInfo` derived from config — and skips the per-device settings
19962
+ * read that produces them. Everything identifying a device (id, name,
19963
+ * type, online, features, isCamera, parent/link ids) is unchanged.
19964
+ * Do not use it for dispatch routing, which needs `sourceInfo`.
19965
+ */
19966
+ projection: _enum(["full", "slim"]).optional(),
19967
+ /** Return only camera devices. Filtering server-side instead of
19968
+ * shipping 293 rows to find 12. */
19969
+ isCamera: boolean().optional()
19970
+ }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
19428
19971
  mode: DeviceLinkModeSchema,
19429
19972
  devices: array(LinkedDeviceSchema)
19430
19973
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
@@ -19863,14 +20406,14 @@ var LlmProfileSchema = object({
19863
20406
  /** ConfigUISchema tree passed through untyped on the wire (the
19864
20407
  * notification-output `ConfigSchemaPassthrough` precedent at
19865
20408
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
19866
- var ConfigSchemaPassthrough$1 = unknown();
20409
+ var ConfigSchemaPassthrough = unknown();
19867
20410
  var LlmProfileKindDescriptorSchema = object({
19868
20411
  kind: LlmProfileKindSchema,
19869
20412
  label: string(),
19870
20413
  icon: string(),
19871
20414
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
19872
20415
  addonId: string(),
19873
- configSchema: ConfigSchemaPassthrough$1
20416
+ configSchema: ConfigSchemaPassthrough
19874
20417
  });
19875
20418
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
19876
20419
  var LlmDefaultSchema = object({
@@ -20360,282 +20903,143 @@ var StartEmbeddedInputSchema = object({
20360
20903
  });
20361
20904
  var StartEmbeddedResultSchema = object({
20362
20905
  id: string(),
20363
- url: string()
20364
- });
20365
- var StatusSchema = object({
20366
- brokerCount: number(),
20367
- embeddedRunning: boolean()
20368
- });
20369
- 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);
20370
- var NetworkEndpointSchema = object({
20371
- url: string(),
20372
- hostname: string(),
20373
- port: number(),
20374
- protocol: _enum(["http", "https"])
20375
- });
20376
- var NetworkAccessStatusSchema = object({
20377
- connected: boolean(),
20378
- endpoint: NetworkEndpointSchema.nullable(),
20379
- error: string().optional()
20380
- });
20381
- /**
20382
- * Optional, richer endpoint shape returned by providers that expose
20383
- * MORE than one ingress concurrently (Tailscale Ingress with mixed
20384
- * serve+funnel rules, future ngrok multi-tunnel, …). Each entry carries
20385
- * the originating provider config (mode + sourcePort) so the
20386
- * orchestrator UI can label rows distinctly. Providers that expose only
20387
- * one endpoint just omit `listEndpoints` from their provider impl.
20388
- */
20389
- var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
20390
- /**
20391
- * Stable id within the provider — typically `<mode>-<sourcePort>` so
20392
- * the orchestrator can dedupe across `listEndpoints` polls.
20393
- */
20394
- id: string(),
20395
- /** Operator-facing label (mirrors `MeshEndpoint.label`). */
20396
- label: string(),
20397
- /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
20398
- mode: string().optional(),
20399
- /** Originating local port the ingress fronts (informational). */
20400
- sourcePort: number().optional()
20401
- });
20402
- method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
20403
- /**
20404
- * notification-output — canonical, capability-gated notification delivery.
20405
- *
20406
- * Apprise-derived model (see
20407
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
20408
- * callers emit ONE canonical `Notification`; each provider declares a
20409
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
20410
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
20411
- * message to what the kind supports — callers never special-case a service.
20412
- *
20413
- * DESIGN DECISIONS (locked):
20414
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
20415
- * `setTargetEnabled`), each provider persisting via the `settings-store`
20416
- * cap. Rationale: the admin UI needs one uniform surface across the
20417
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
20418
- * alternative would fork the UI per addon and cannot host the
20419
- * discovery→adopt flow.
20420
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
20421
- * the generated cap-mount auto-`concatCollection`-fans them across every
20422
- * registered provider (notifiers addon + HA addon) so one catalog is
20423
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
20424
- * `addonId` the generated collection router extracts from the call input.
20425
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
20426
- * transformer) + UDS MsgPack both round-trip typed arrays — already used by
20427
- * `storage` / `storage-provider` / `recording` caps over the same path. No
20428
- * base64 fallback needed.
20429
- *
20430
- * TODO (deferred, closed-set change — separate decision): add
20431
- * `providerKind: 'notify'` so notification providers surface on the unified
20432
- * admin "Integrations" page.
20433
- */
20434
- /**
20435
- * Zentik-derived typed-media enum — the superset across every kind. Each
20436
- * adapter picks what it supports and the degrade engine filters the rest.
20437
- */
20438
- var AttachmentMediaTypeSchema = _enum([
20439
- "image",
20440
- "video",
20441
- "gif",
20442
- "audio",
20443
- "icon"
20444
- ]);
20445
- /**
20446
- * A single attachment. Exactly one of `url` (remote source, most adapters
20447
- * prefer this) or `bytes` (inline source; required for Pushover-style
20448
- * bytes-only kinds) MUST be present — the degrade engine expresses a
20449
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
20450
- */
20451
- var AttachmentSchema = object({
20452
- mediaType: AttachmentMediaTypeSchema,
20453
- url: string().optional(),
20454
- bytes: _instanceof(Uint8Array).optional(),
20455
- mime: string().optional(),
20456
- name: string().optional()
20457
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
20458
- var NotificationFormatSchema = _enum([
20459
- "text",
20460
- "markdown",
20461
- "html"
20462
- ]);
20463
- /** A single tap-through action button. */
20464
- var NotificationActionSchema = object({
20465
- id: string(),
20466
- label: string(),
20467
- url: string().optional()
20468
- });
20469
- /**
20470
- * The canonical notification. `body` is the only hard field (Apprise model).
20471
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
20472
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
20473
- * the adapter maps this ordinal onto its native level. `level?` is an
20474
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
20475
- * `priority` for that one target.
20476
- */
20477
- var NotificationSchema = object({
20478
- body: string(),
20479
- title: string().optional(),
20480
- format: NotificationFormatSchema.default("text"),
20481
- priority: number().int().min(1).max(5).default(3),
20482
- level: string().optional(),
20483
- attachments: array(AttachmentSchema).optional(),
20484
- clickUrl: string().optional(),
20485
- actions: array(NotificationActionSchema).optional(),
20486
- sound: string().optional(),
20487
- ttl: number().optional(),
20488
- tag: string().optional(),
20489
- deviceId: number().optional(),
20490
- eventId: string().optional(),
20491
- metadata: record(string(), unknown()).optional()
20492
- });
20493
- /** One declared native severity/priority level for a kind. */
20494
- var TargetKindLevelSchema = object({
20495
- id: string(),
20496
- label: string(),
20497
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
20498
- ordinal: number().int().min(1).max(5).nullable(),
20499
- flags: object({
20500
- critical: boolean().optional(),
20501
- silent: boolean().optional(),
20502
- noPush: boolean().optional()
20503
- }).optional(),
20504
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
20505
- requires: array(string()).optional(),
20506
- description: string().optional()
20906
+ url: string()
20507
20907
  });
20508
- /** The full capability block consulted before dispatch. */
20509
- var TargetKindCapsSchema = object({
20510
- attachments: object({
20511
- mediaTypes: array(AttachmentMediaTypeSchema),
20512
- mode: _enum([
20513
- "url",
20514
- "bytes",
20515
- "both"
20516
- ]),
20517
- max: number().int().nonnegative(),
20518
- maxBytes: number().int().positive().optional()
20519
- }),
20520
- /** Max action buttons (0 = none). */
20521
- actions: number().int().nonnegative(),
20522
- levels: array(TargetKindLevelSchema),
20523
- format: array(NotificationFormatSchema),
20524
- clickUrl: boolean(),
20525
- sound: boolean(),
20526
- ttl: boolean(),
20527
- bodyMaxLen: number().int().positive()
20908
+ var StatusSchema = object({
20909
+ brokerCount: number(),
20910
+ embeddedRunning: boolean()
20911
+ });
20912
+ 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);
20913
+ var NetworkEndpointSchema = object({
20914
+ url: string(),
20915
+ hostname: string(),
20916
+ port: number(),
20917
+ protocol: _enum(["http", "https"])
20918
+ });
20919
+ var NetworkAccessStatusSchema = object({
20920
+ connected: boolean(),
20921
+ endpoint: NetworkEndpointSchema.nullable(),
20922
+ error: string().optional()
20528
20923
  });
20529
20924
  /**
20530
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
20531
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
20532
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`)
20533
- * the union is large and not meant for runtime validation here; the exported
20534
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
20925
+ * Optional, richer endpoint shape returned by providers that expose
20926
+ * MORE than one ingress concurrently (Tailscale Ingress with mixed
20927
+ * serve+funnel rules, future ngrok multi-tunnel, ). Each entry carries
20928
+ * the originating provider config (mode + sourcePort) so the
20929
+ * orchestrator UI can label rows distinctly. Providers that expose only
20930
+ * one endpoint just omit `listEndpoints` from their provider impl.
20535
20931
  */
20536
- var ConfigSchemaPassthrough = unknown();
20537
- var TargetKindSchema = object({
20538
- kind: string(),
20539
- label: string(),
20540
- icon: string(),
20541
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
20542
- addonId: string(),
20543
- /**
20544
- * URL of the kind's bundled BRAND icon, served by the providing addon over
20545
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
20546
- * when the addon bundles no icon for that kind — the client then falls back
20547
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
20548
- *
20549
- * Root-relative on purpose: it resolves against whatever origin serves a web
20550
- * client, and a native client joins it onto its own hub base.
20551
- *
20552
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
20553
- * field that survived only because the runtime cap-router forwards provider
20554
- * output verbatim — so every consumer had to re-declare it by hand to stop
20555
- * its own Zod parse from stripping it, and the whole arrangement would have
20556
- * broken silently the moment output validation was tightened anywhere.
20557
- */
20558
- iconUrl: string().optional(),
20932
+ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
20559
20933
  /**
20560
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
20561
- *
20562
- * The server knows this and therefore says it, because the client cannot
20563
- * safely guess: a React-Native client renders SVG and raster through two
20564
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
20565
- * not decode SVG on iOS/Android), so without this it silently fell back to a
20566
- * placeholder glyph for every vector icon while the web build looked fine.
20567
- *
20568
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
20569
- * not been updated — a client that cannot determine the type should prefer
20570
- * its raster path, which is the safe default for an unknown image.
20934
+ * Stable id within the provider typically `<mode>-<sourcePort>` so
20935
+ * the orchestrator can dedupe across `listEndpoints` polls.
20571
20936
  */
20572
- iconMediaType: string().optional(),
20573
- configSchema: ConfigSchemaPassthrough,
20574
- supportsDiscovery: boolean(),
20575
- caps: TargetKindCapsSchema
20937
+ id: string(),
20938
+ /** Operator-facing label (mirrors `MeshEndpoint.label`). */
20939
+ label: string(),
20940
+ /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
20941
+ mode: string().optional(),
20942
+ /** Originating local port the ingress fronts (informational). */
20943
+ sourcePort: number().optional()
20576
20944
  });
20945
+ method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
20577
20946
  /**
20578
- * A persisted target. `config` holds secrets; providers REDACT secret fields
20579
- * (return a presence marker only) when serving `listTargets` — never
20580
- * round-trip a stored secret to the UI.
20581
- */
20582
- var TargetSchema = object({
20583
- id: string(),
20584
- name: string(),
20585
- kind: string(),
20586
- addonId: string(),
20947
+ * core-blocks user-authored TypeScript, stored in the kernel and executed in
20948
+ * its own process.
20949
+ *
20950
+ * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
20951
+ *
20952
+ * The first use is **owning devices without being a device provider**: a block
20953
+ * declares devices under a system or custom integration and drives their state,
20954
+ * with the same `ctx` an addon gets. Automations come later; nothing here
20955
+ * models a trigger.
20956
+ *
20957
+ * **Stated plainly, because it does not change by being true:** a block has an
20958
+ * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
20959
+ * with no review step. What makes that survivable is not a sandbox, it is
20960
+ * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
20961
+ * so a block that throws or never returns is marked `failed` and visible
20962
+ * instead of taking the hub with it (D6). Every method here is admin-only, and
20963
+ * must stay so.
20964
+ */
20965
+ /** Where a block runs. The operator chooses — a block driving a device on an
20966
+ * agent is the reason placement is not fixed to the hub. */
20967
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
20968
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
20969
+ * a failing block reads the same way a failing addon does. */
20970
+ var CoreBlockStatusSchema = _enum([
20971
+ "stopped",
20972
+ "starting",
20973
+ "running",
20974
+ "failed"
20975
+ ]);
20976
+ /** Client-authored fields. */
20977
+ var CoreBlockInputSchema = object({
20978
+ name: string().min(1).max(120),
20979
+ /** TypeScript source. Compiled server-side before it is ever stored — a
20980
+ * block that does not compile is a fork failure the operator would meet
20981
+ * minutes later, in a log, instead of in the editor. */
20982
+ code: string().max(2e5),
20587
20983
  enabled: boolean(),
20588
- config: record(string(), unknown())
20589
- });
20590
- /** A discovery-surfaced candidate (config is partial + non-secret). */
20591
- var DiscoveredTargetSchema = object({
20592
- kind: string(),
20593
- suggestedName: string(),
20594
- config: record(string(), unknown())
20984
+ placement: CoreBlockPlacementSchema,
20985
+ /**
20986
+ * Integration the block's devices hang from. Absent = the system integration
20987
+ * blocks share. A block may declare its own instead.
20988
+ */
20989
+ integrationId: string().optional()
20595
20990
  });
20596
- /** The degrade engine's report — what was resolved / dropped / degraded. */
20597
- var RenderedAsSchema = object({
20598
- level: string(),
20599
- format: NotificationFormatSchema,
20600
- attachmentsSent: number().int().nonnegative(),
20601
- actionsSent: number().int().nonnegative(),
20602
- truncated: boolean(),
20603
- dropped: array(string())
20991
+ /** A stored block. */
20992
+ var CoreBlockSchema = CoreBlockInputSchema.extend({
20993
+ id: string(),
20994
+ createdAt: number(),
20995
+ updatedAt: number(),
20996
+ /** Server-stamped author. */
20997
+ createdBy: string(),
20998
+ status: CoreBlockStatusSchema,
20999
+ /**
21000
+ * Why the block is not running, when it is not. The operator's ONLY window
21001
+ * into a block that failed at load — a block that is silently absent is the
21002
+ * failure mode this whole feature has to avoid.
21003
+ */
21004
+ lastError: string().optional(),
21005
+ /** Ms epoch of the last state change. */
21006
+ lastChangedAt: number()
20604
21007
  });
20605
- var SendResultSchema = object({
20606
- success: boolean(),
21008
+ /** What a compile attempt produced. */
21009
+ var CoreBlockCompileResultSchema = object({
21010
+ ok: boolean(),
21011
+ /** Present when `ok` is false — the first error, in the author's words. */
20607
21012
  error: string().optional(),
20608
- renderedAs: RenderedAsSchema.optional()
21013
+ line: number().optional(),
21014
+ column: number().optional()
20609
21015
  });
20610
- /** Same shape as SendResult kept as a distinct name for the test panel. */
20611
- var TestResultSchema = SendResultSchema;
20612
- var notificationOutputCapability = {
20613
- name: "notification-output",
20614
- scope: "system",
20615
- mode: "collection",
20616
- methods: {
20617
- listTargetKinds: method(object({}), array(TargetKindSchema)),
20618
- listTargets: method(object({}), array(TargetSchema)),
20619
- discoverTargets: method(object({
20620
- kind: string(),
20621
- config: record(string(), unknown()).optional()
20622
- }), array(DiscoveredTargetSchema)),
20623
- send: method(object({
20624
- targetId: string(),
20625
- notification: NotificationSchema
20626
- }), SendResultSchema, { kind: "mutation" }),
20627
- testTarget: method(object({
20628
- targetId: string(),
20629
- sample: NotificationSchema.optional()
20630
- }), TestResultSchema, { kind: "mutation" }),
20631
- upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
20632
- deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
20633
- setTargetEnabled: method(object({
20634
- targetId: string(),
20635
- enabled: boolean()
20636
- }), _void(), { kind: "mutation" })
20637
- }
20638
- };
21016
+ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }), method(object({ blockId: string() }), object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }), method(object({ block: CoreBlockInputSchema }), object({ block: CoreBlockSchema }), {
21017
+ kind: "mutation",
21018
+ auth: "admin",
21019
+ caller: "required"
21020
+ }), method(object({
21021
+ blockId: string(),
21022
+ block: CoreBlockInputSchema.partial()
21023
+ }), object({ block: CoreBlockSchema }), {
21024
+ kind: "mutation",
21025
+ auth: "admin",
21026
+ caller: "required"
21027
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
21028
+ kind: "mutation",
21029
+ auth: "admin"
21030
+ }), method(object({
21031
+ blockId: string(),
21032
+ enabled: boolean()
21033
+ }), object({ block: CoreBlockSchema }), {
21034
+ kind: "mutation",
21035
+ auth: "admin"
21036
+ }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
21037
+ kind: "mutation",
21038
+ auth: "admin"
21039
+ }), method(object({}), object({ libs: array(object({
21040
+ filePath: string(),
21041
+ content: string()
21042
+ })) }), { auth: "admin" });
20639
21043
  /**
20640
21044
  * Zod schemas for persisted record types.
20641
21045
  *
@@ -20883,6 +21287,11 @@ var EventKindDescriptorSchema = object({
20883
21287
  deviceId: number()
20884
21288
  })
20885
21289
  });
21290
+ /** One camera's event vocabulary, as returned by `listEventKindsBatch`. */
21291
+ var EventKindsForDeviceSchema = object({
21292
+ deviceId: number(),
21293
+ kinds: array(EventKindDescriptorSchema).readonly()
21294
+ });
20886
21295
  var SensorEventSchema = object({
20887
21296
  id: string(),
20888
21297
  /** The CAMERA the event is attributed to (a sensor linked to N cameras
@@ -21139,6 +21548,19 @@ var MediaFileSchema = object({
21139
21548
  sizeBytes: number(),
21140
21549
  timestamp: number()
21141
21550
  });
21551
+ /**
21552
+ * One media row WITHOUT its bytes.
21553
+ *
21554
+ * A track's media is 5-8 MB of base64 (measured: 7.67 MB across 23 files for a
21555
+ * 140 s track), and a client that renders tiles from the media data plane needs
21556
+ * to know only WHAT EXISTS — the bytes then arrive per tile, lazily, over HTTP
21557
+ * with an immutable cache, instead of all at once inside a tRPC response that
21558
+ * blocks the whole view.
21559
+ *
21560
+ * `sizeBytes` is carried because it is what lets a client decide between the
21561
+ * stored blob and a `?variant=thumb` rendering without fetching either.
21562
+ */
21563
+ var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
21142
21564
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
21143
21565
  var MAX_EVENT_QUERY_LIMIT = 5e3;
21144
21566
  var DeviceEventQueryInput = object({
@@ -21288,7 +21710,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21288
21710
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
21289
21711
  kind: "mutation",
21290
21712
  auth: "admin"
21291
- }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({
21713
+ }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({ deviceIds: array(number()).min(1).max(200) }), array(EventKindsForDeviceSchema).readonly()), method(object({
21292
21714
  deviceId: number(),
21293
21715
  since: number().optional(),
21294
21716
  until: number().optional(),
@@ -21362,7 +21784,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21362
21784
  }), array(MediaFileSchema).readonly()), method(object({
21363
21785
  trackId: string(),
21364
21786
  kinds: array(MediaFileKindEnum).optional()
21365
- }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
21787
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
21366
21788
  deviceId: number(),
21367
21789
  timestamp: number(),
21368
21790
  frameWidth: number(),
@@ -26041,6 +26463,54 @@ Object.freeze({
26041
26463
  addonId: null,
26042
26464
  access: "create"
26043
26465
  },
26466
+ "coreBlocks.compile": {
26467
+ capName: "core-blocks",
26468
+ capScope: "system",
26469
+ addonId: null,
26470
+ access: "create"
26471
+ },
26472
+ "coreBlocks.create": {
26473
+ capName: "core-blocks",
26474
+ capScope: "system",
26475
+ addonId: null,
26476
+ access: "create"
26477
+ },
26478
+ "coreBlocks.delete": {
26479
+ capName: "core-blocks",
26480
+ capScope: "system",
26481
+ addonId: null,
26482
+ access: "delete"
26483
+ },
26484
+ "coreBlocks.get": {
26485
+ capName: "core-blocks",
26486
+ capScope: "system",
26487
+ addonId: null,
26488
+ access: "view"
26489
+ },
26490
+ "coreBlocks.getTypeDefs": {
26491
+ capName: "core-blocks",
26492
+ capScope: "system",
26493
+ addonId: null,
26494
+ access: "view"
26495
+ },
26496
+ "coreBlocks.list": {
26497
+ capName: "core-blocks",
26498
+ capScope: "system",
26499
+ addonId: null,
26500
+ access: "view"
26501
+ },
26502
+ "coreBlocks.setEnabled": {
26503
+ capName: "core-blocks",
26504
+ capScope: "system",
26505
+ addonId: null,
26506
+ access: "create"
26507
+ },
26508
+ "coreBlocks.update": {
26509
+ capName: "core-blocks",
26510
+ capScope: "system",
26511
+ addonId: null,
26512
+ access: "create"
26513
+ },
26044
26514
  "cover.close": {
26045
26515
  capName: "cover",
26046
26516
  capScope: "device",
@@ -27949,12 +28419,24 @@ Object.freeze({
27949
28419
  addonId: null,
27950
28420
  access: "create"
27951
28421
  },
28422
+ "notificationRules.cancelSnooze": {
28423
+ capName: "notification-rules",
28424
+ capScope: "system",
28425
+ addonId: null,
28426
+ access: "create"
28427
+ },
27952
28428
  "notificationRules.createRule": {
27953
28429
  capName: "notification-rules",
27954
28430
  capScope: "system",
27955
28431
  addonId: null,
27956
28432
  access: "create"
27957
28433
  },
28434
+ "notificationRules.createSnooze": {
28435
+ capName: "notification-rules",
28436
+ capScope: "system",
28437
+ addonId: null,
28438
+ access: "create"
28439
+ },
27958
28440
  "notificationRules.deleteRule": {
27959
28441
  capName: "notification-rules",
27960
28442
  capScope: "system",
@@ -27985,6 +28467,12 @@ Object.freeze({
27985
28467
  addonId: null,
27986
28468
  access: "view"
27987
28469
  },
28470
+ "notificationRules.listSnoozes": {
28471
+ capName: "notification-rules",
28472
+ capScope: "system",
28473
+ addonId: null,
28474
+ access: "view"
28475
+ },
27988
28476
  "notificationRules.setRuleEnabled": {
27989
28477
  capName: "notification-rules",
27990
28478
  capScope: "system",
@@ -28189,6 +28677,12 @@ Object.freeze({
28189
28677
  addonId: null,
28190
28678
  access: "view"
28191
28679
  },
28680
+ "pipelineAnalytics.listEventKindsBatch": {
28681
+ capName: "pipeline-analytics",
28682
+ capScope: "device",
28683
+ addonId: null,
28684
+ access: "view"
28685
+ },
28192
28686
  "pipelineAnalytics.listOpsLog": {
28193
28687
  capName: "pipeline-analytics",
28194
28688
  capScope: "device",
@@ -28201,6 +28695,12 @@ Object.freeze({
28201
28695
  addonId: null,
28202
28696
  access: "view"
28203
28697
  },
28698
+ "pipelineAnalytics.listTrackMedia": {
28699
+ capName: "pipeline-analytics",
28700
+ capScope: "device",
28701
+ addonId: null,
28702
+ access: "view"
28703
+ },
28204
28704
  "pipelineAnalytics.listTracks": {
28205
28705
  capName: "pipeline-analytics",
28206
28706
  capScope: "device",
@@ -30671,11 +31171,18 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30671
31171
  async onActivate() {
30672
31172
  await super.onActivate();
30673
31173
  await this.refreshFeatures();
30674
- await this.subscribeBrokerEntity();
30675
- this.deviceLogger.info("HA entity ready", { meta: {
31174
+ if (await this.subscribeBrokerEntity()) {
31175
+ this.deviceLogger.info("HA entity ready", { meta: {
31176
+ entityId: this.entityId,
31177
+ type: this.ctx.deviceMeta.type,
31178
+ ...this.features.length > 0 ? { features: [...this.features] } : {}
31179
+ } });
31180
+ return;
31181
+ }
31182
+ this.deviceLogger.warn("HA entity registered but NOT subscribed — it will receive nothing", { meta: {
30676
31183
  entityId: this.entityId,
30677
- type: this.ctx.deviceMeta.type,
30678
- ...this.features.length > 0 ? { features: [...this.features] } : {}
31184
+ brokerId: this.brokerId,
31185
+ type: this.ctx.deviceMeta.type
30679
31186
  } });
30680
31187
  }
30681
31188
  async removeDevice() {
@@ -30779,6 +31286,8 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30779
31286
  * already-attached handler and `handleStatePush` fires with the real
30780
31287
  * current state (seeding the cap-state slice at construction time).
30781
31288
  */
31289
+ /** True when the entity is genuinely subscribed. The caller uses it to
31290
+ * decide whether "ready" is an honest thing to log. */
30782
31291
  async subscribeBrokerEntity() {
30783
31292
  try {
30784
31293
  const result = await this.ctx.api.broker.subscribe.mutate({
@@ -30786,12 +31295,14 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30786
31295
  filter: { entityIds: [this.entityId] }
30787
31296
  });
30788
31297
  this.brokerSubscriptionId = result.subscriptionId;
31298
+ return true;
30789
31299
  } catch (err) {
30790
31300
  this.deviceLogger.warn("ha-child failed to register broker subscription", { meta: {
30791
31301
  entityId: this.entityId,
30792
31302
  brokerId: this.brokerId,
30793
31303
  error: errMsg(err)
30794
31304
  } });
31305
+ return false;
30795
31306
  }
30796
31307
  }
30797
31308
  attachEventBusListener() {
@@ -36815,6 +37326,7 @@ function homeassistantTargetKind(addonId) {
36815
37326
  max: 1
36816
37327
  },
36817
37328
  actions: 3,
37329
+ actionIcons: true,
36818
37330
  levels: [
36819
37331
  {
36820
37332
  id: "default",
@@ -36870,6 +37382,21 @@ function applyLevel(data, level) {
36870
37382
  }
36871
37383
  data["push"] = push;
36872
37384
  }
37385
+ var HA_ACTION_ICONS = {
37386
+ acknowledge: "mdi:check",
37387
+ dismiss: "mdi:close",
37388
+ silence: "mdi:bell-off",
37389
+ view: "mdi:eye",
37390
+ play: "mdi:play",
37391
+ open: "mdi:door-open",
37392
+ close: "mdi:door-closed",
37393
+ lock: "mdi:lock",
37394
+ unlock: "mdi:lock-open",
37395
+ arm: "mdi:shield-check",
37396
+ disarm: "mdi:shield-off",
37397
+ light: "mdi:lightbulb",
37398
+ alert: "mdi:alert"
37399
+ };
36873
37400
  /**
36874
37401
  * Build the `notify.<service>` `service_data` from a prepared (degraded)
36875
37402
  * notification. PURE — no WS, unit-testable in isolation.
@@ -36886,7 +37413,8 @@ function buildNotifyServiceData(prepared) {
36886
37413
  if (prepared.actions.length > 0) data["actions"] = prepared.actions.map((a) => ({
36887
37414
  action: a.id,
36888
37415
  title: a.label,
36889
- ...a.url !== void 0 ? { uri: a.url } : {}
37416
+ ...a.url !== void 0 ? { uri: a.url } : {},
37417
+ ...a.icon !== void 0 ? { icon: HA_ACTION_ICONS[a.icon] } : {}
36890
37418
  }));
36891
37419
  if (prepared.clickUrl !== null) {
36892
37420
  data["url"] = prepared.clickUrl;
@@ -37230,6 +37758,19 @@ async function reconcileBroker(brokerId, deps) {
37230
37758
  * whose integration no longer exists should be cleaned up. Brokers with no
37231
37759
  * integrationId were created manually and are never auto-removed.
37232
37760
  */
37761
+ /**
37762
+ * Brokers whose spawning integration is gone.
37763
+ *
37764
+ * **The caller REPORTS these; it does not delete them.** This function used to
37765
+ * drive a delete, and on 2026-08-05 that erased the operator's Home Assistant
37766
+ * broker — host and token — when `integrations.list` returned an empty array
37767
+ * during a hub restart: nothing "survived", so everything was an orphan. A
37768
+ * broker cannot be rebuilt from an integration record, so the loss was
37769
+ * permanent and the doorbell simply stopped existing.
37770
+ *
37771
+ * Kept as a pure query because naming an orphan is useful; acting on one
37772
+ * automatically is not.
37773
+ */
37233
37774
  function computeBrokerCleanup(brokers, survivingIntegrationIds) {
37234
37775
  return brokers.filter((b) => b.integrationId !== void 0 && !survivingIntegrationIds.has(b.integrationId)).map((b) => b.id);
37235
37776
  }
@@ -37817,15 +38358,15 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
37817
38358
  }
37818
38359
  const linkedBrokers = linkBrokersToIntegrations(this.config.brokers, integrationsWithBrokerId);
37819
38360
  if (linkedBrokers.some((b, i) => b !== this.config.brokers[i])) await this.updateGlobalSettings({ brokers: linkedBrokers });
37820
- const toRemove = computeBrokerCleanup(this.config.brokers, survivingIntegrationIds);
37821
- if (toRemove.length === 0) return;
37822
- for (const id of toRemove) await this.requireRegistry().removeEntry(id);
37823
- const nextBrokers = this.config.brokers.filter((b) => !toRemove.includes(b.id));
37824
- await this.updateGlobalSettings({ brokers: nextBrokers });
38361
+ const orphans = computeBrokerCleanup(this.config.brokers, survivingIntegrationIds);
37825
38362
  this.ctx.logger.info("integration→broker reconcile", { meta: {
37826
- brokerCount: nextBrokers.length,
38363
+ brokerCount: this.config.brokers.length,
37827
38364
  integrationCount: haIntegrations.length,
37828
- removed: toRemove.length
38365
+ orphans: orphans.length
38366
+ } });
38367
+ if (orphans.length > 0) this.ctx.logger.warn("broker entries have no surviving integration — KEPT", { meta: {
38368
+ brokerIds: orphans,
38369
+ integrationCount: haIntegrations.length
37829
38370
  } });
37830
38371
  } catch (err) {
37831
38372
  this.ctx.logger.warn("integration→broker reconcile failed", { meta: { error: errMsg(err) } });