@camstack/addon-provider-homeassistant 1.2.11 → 1.2.13

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 +863 -283
  2. package/dist/addon.mjs +863 -283
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -9466,8 +9466,11 @@ function prepareNotification(caps, n) {
9466
9466
  });
9467
9467
  }
9468
9468
  const inActions = n.actions ?? [];
9469
- const actions = inActions.slice(0, Math.max(0, caps.actions));
9470
- if (inActions.length > actions.length) dropped.push("actions");
9469
+ const kept = inActions.slice(0, Math.max(0, caps.actions));
9470
+ if (inActions.length > kept.length) dropped.push("actions");
9471
+ const iconsSupported = caps.actionIcons === true;
9472
+ if (!iconsSupported && kept.some((a) => a.icon !== void 0)) dropped.push("actionIcons");
9473
+ const actions = iconsSupported ? kept : kept.map(({ icon: _icon, ...rest }) => rest);
9471
9474
  let clickUrl = null;
9472
9475
  if (n.clickUrl !== void 0) if (caps.clickUrl) clickUrl = n.clickUrl;
9473
9476
  else dropped.push("clickUrl");
@@ -9566,6 +9569,290 @@ var MaskGridDimsSchema = object({
9566
9569
  height: number()
9567
9570
  });
9568
9571
  /**
9572
+ * notification-output — canonical, capability-gated notification delivery.
9573
+ *
9574
+ * Apprise-derived model (see
9575
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
9576
+ * callers emit ONE canonical `Notification`; each provider declares a
9577
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
9578
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
9579
+ * message to what the kind supports — callers never special-case a service.
9580
+ *
9581
+ * DESIGN DECISIONS (locked):
9582
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
9583
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
9584
+ * cap. Rationale: the admin UI needs one uniform surface across the
9585
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
9586
+ * alternative would fork the UI per addon and cannot host the
9587
+ * discovery→adopt flow.
9588
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
9589
+ * the generated cap-mount auto-`concatCollection`-fans them across every
9590
+ * registered provider (notifiers addon + HA addon) so one catalog is
9591
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
9592
+ * `addonId` the generated collection router extracts from the call input.
9593
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
9594
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
9595
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
9596
+ * base64 fallback needed.
9597
+ *
9598
+ * TODO (deferred, closed-set change — separate decision): add
9599
+ * `providerKind: 'notify'` so notification providers surface on the unified
9600
+ * admin "Integrations" page.
9601
+ */
9602
+ /**
9603
+ * Zentik-derived typed-media enum — the superset across every kind. Each
9604
+ * adapter picks what it supports and the degrade engine filters the rest.
9605
+ */
9606
+ var AttachmentMediaTypeSchema = _enum([
9607
+ "image",
9608
+ "video",
9609
+ "gif",
9610
+ "audio",
9611
+ "icon"
9612
+ ]);
9613
+ /**
9614
+ * A single attachment. Exactly one of `url` (remote source, most adapters
9615
+ * prefer this) or `bytes` (inline source; required for Pushover-style
9616
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
9617
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
9618
+ */
9619
+ var AttachmentSchema = object({
9620
+ mediaType: AttachmentMediaTypeSchema,
9621
+ url: string().optional(),
9622
+ bytes: _instanceof(Uint8Array).optional(),
9623
+ mime: string().optional(),
9624
+ name: string().optional()
9625
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
9626
+ var NotificationFormatSchema = _enum([
9627
+ "text",
9628
+ "markdown",
9629
+ "html"
9630
+ ]);
9631
+ /**
9632
+ * The CLOSED icon vocabulary an action button may use.
9633
+ *
9634
+ * A closed set, not a free string, and that is the whole point: an arbitrary
9635
+ * icon name is one that ntfy renders, zentik silently drops, and nobody
9636
+ * notices — the same class of gap as a zone vocabulary nothing produced
9637
+ * ([D35](../../../docs/decisions/adr-0035.md)). Every adapter maps this set or
9638
+ * declares `actionIcons: false` and the degrade engine strips the field.
9639
+ *
9640
+ * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
9641
+ * renderer's icon set; "acknowledge" survives an adapter that draws it
9642
+ * differently.
9643
+ */
9644
+ var NotificationActionIconSchema = _enum([
9645
+ "acknowledge",
9646
+ "dismiss",
9647
+ "silence",
9648
+ "view",
9649
+ "play",
9650
+ "open",
9651
+ "close",
9652
+ "lock",
9653
+ "unlock",
9654
+ "arm",
9655
+ "disarm",
9656
+ "light",
9657
+ "alert"
9658
+ ]);
9659
+ /** A single tap-through action button. */
9660
+ var NotificationActionSchema = object({
9661
+ id: string(),
9662
+ label: string(),
9663
+ url: string().optional(),
9664
+ /** Dropped by the degrade engine for a kind with `caps.actionIcons: false`. */
9665
+ icon: NotificationActionIconSchema.optional(),
9666
+ /**
9667
+ * Renders in a warning style where the notifier supports it.
9668
+ *
9669
+ * A HINT, never a gate. The callback's authority is its token and nothing
9670
+ * else — see `notification-center/action-token.ts` for what that does and
9671
+ * does not buy.
9672
+ */
9673
+ destructive: boolean().optional()
9674
+ });
9675
+ /**
9676
+ * The canonical notification. `body` is the only hard field (Apprise model).
9677
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
9678
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
9679
+ * the adapter maps this ordinal onto its native level. `level?` is an
9680
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
9681
+ * `priority` for that one target.
9682
+ */
9683
+ var NotificationSchema = object({
9684
+ body: string(),
9685
+ title: string().optional(),
9686
+ format: NotificationFormatSchema.default("text"),
9687
+ priority: number().int().min(1).max(5).default(3),
9688
+ level: string().optional(),
9689
+ attachments: array(AttachmentSchema).optional(),
9690
+ clickUrl: string().optional(),
9691
+ actions: array(NotificationActionSchema).optional(),
9692
+ sound: string().optional(),
9693
+ ttl: number().optional(),
9694
+ tag: string().optional(),
9695
+ deviceId: number().optional(),
9696
+ eventId: string().optional(),
9697
+ metadata: record(string(), unknown()).optional()
9698
+ });
9699
+ /** One declared native severity/priority level for a kind. */
9700
+ var TargetKindLevelSchema = object({
9701
+ id: string(),
9702
+ label: string(),
9703
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
9704
+ ordinal: number().int().min(1).max(5).nullable(),
9705
+ flags: object({
9706
+ critical: boolean().optional(),
9707
+ silent: boolean().optional(),
9708
+ noPush: boolean().optional()
9709
+ }).optional(),
9710
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
9711
+ requires: array(string()).optional(),
9712
+ description: string().optional()
9713
+ });
9714
+ /** The full capability block consulted before dispatch. */
9715
+ var TargetKindCapsSchema = object({
9716
+ attachments: object({
9717
+ mediaTypes: array(AttachmentMediaTypeSchema),
9718
+ mode: _enum([
9719
+ "url",
9720
+ "bytes",
9721
+ "both"
9722
+ ]),
9723
+ max: number().int().nonnegative(),
9724
+ maxBytes: number().int().positive().optional()
9725
+ }),
9726
+ /** Max action buttons (0 = none). */
9727
+ actions: number().int().nonnegative(),
9728
+ /**
9729
+ * Whether this kind renders a per-action ICON.
9730
+ *
9731
+ * `.optional()`, deliberately NOT `.default(false)`: a Zod default does not
9732
+ * run on the addon cap path — three production failures in one day taught
9733
+ * this repo that once. Absent is read as false by the degrade engine, which
9734
+ * is the safe direction: an icon that is not rendered costs nothing, an icon
9735
+ * assumed and dropped costs the operator's trust in the field.
9736
+ */
9737
+ actionIcons: boolean().optional(),
9738
+ levels: array(TargetKindLevelSchema),
9739
+ format: array(NotificationFormatSchema),
9740
+ clickUrl: boolean(),
9741
+ sound: boolean(),
9742
+ ttl: boolean(),
9743
+ bodyMaxLen: number().int().positive()
9744
+ });
9745
+ /**
9746
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
9747
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
9748
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
9749
+ * the union is large and not meant for runtime validation here; the exported
9750
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
9751
+ */
9752
+ var ConfigSchemaPassthrough$1 = unknown();
9753
+ var TargetKindSchema = object({
9754
+ kind: string(),
9755
+ label: string(),
9756
+ icon: string(),
9757
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
9758
+ addonId: string(),
9759
+ /**
9760
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
9761
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
9762
+ * when the addon bundles no icon for that kind — the client then falls back
9763
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
9764
+ *
9765
+ * Root-relative on purpose: it resolves against whatever origin serves a web
9766
+ * client, and a native client joins it onto its own hub base.
9767
+ *
9768
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
9769
+ * field that survived only because the runtime cap-router forwards provider
9770
+ * output verbatim — so every consumer had to re-declare it by hand to stop
9771
+ * its own Zod parse from stripping it, and the whole arrangement would have
9772
+ * broken silently the moment output validation was tightened anywhere.
9773
+ */
9774
+ iconUrl: string().optional(),
9775
+ /**
9776
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
9777
+ *
9778
+ * The server knows this and therefore says it, because the client cannot
9779
+ * safely guess: a React-Native client renders SVG and raster through two
9780
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
9781
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
9782
+ * placeholder glyph for every vector icon while the web build looked fine.
9783
+ *
9784
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
9785
+ * not been updated — a client that cannot determine the type should prefer
9786
+ * its raster path, which is the safe default for an unknown image.
9787
+ */
9788
+ iconMediaType: string().optional(),
9789
+ configSchema: ConfigSchemaPassthrough$1,
9790
+ supportsDiscovery: boolean(),
9791
+ caps: TargetKindCapsSchema
9792
+ });
9793
+ /**
9794
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
9795
+ * (return a presence marker only) when serving `listTargets` — never
9796
+ * round-trip a stored secret to the UI.
9797
+ */
9798
+ var TargetSchema = object({
9799
+ id: string(),
9800
+ name: string(),
9801
+ kind: string(),
9802
+ addonId: string(),
9803
+ enabled: boolean(),
9804
+ config: record(string(), unknown())
9805
+ });
9806
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
9807
+ var DiscoveredTargetSchema = object({
9808
+ kind: string(),
9809
+ suggestedName: string(),
9810
+ config: record(string(), unknown())
9811
+ });
9812
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
9813
+ var RenderedAsSchema = object({
9814
+ level: string(),
9815
+ format: NotificationFormatSchema,
9816
+ attachmentsSent: number().int().nonnegative(),
9817
+ actionsSent: number().int().nonnegative(),
9818
+ truncated: boolean(),
9819
+ dropped: array(string())
9820
+ });
9821
+ var SendResultSchema = object({
9822
+ success: boolean(),
9823
+ error: string().optional(),
9824
+ renderedAs: RenderedAsSchema.optional()
9825
+ });
9826
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
9827
+ var TestResultSchema = SendResultSchema;
9828
+ var notificationOutputCapability = {
9829
+ name: "notification-output",
9830
+ scope: "system",
9831
+ mode: "collection",
9832
+ methods: {
9833
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
9834
+ listTargets: method(object({}), array(TargetSchema)),
9835
+ discoverTargets: method(object({
9836
+ kind: string(),
9837
+ config: record(string(), unknown()).optional()
9838
+ }), array(DiscoveredTargetSchema)),
9839
+ send: method(object({
9840
+ targetId: string(),
9841
+ notification: NotificationSchema
9842
+ }), SendResultSchema, { kind: "mutation" }),
9843
+ testTarget: method(object({
9844
+ targetId: string(),
9845
+ sample: NotificationSchema.optional()
9846
+ }), TestResultSchema, { kind: "mutation" }),
9847
+ upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
9848
+ deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
9849
+ setTargetEnabled: method(object({
9850
+ targetId: string(),
9851
+ enabled: boolean()
9852
+ }), _void(), { kind: "mutation" })
9853
+ }
9854
+ };
9855
+ /**
9569
9856
  * notification-rules — the Notification Center rule surface (P1 core).
9570
9857
  *
9571
9858
  * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
@@ -9695,7 +9982,115 @@ var NcZoneConditionSchema = object({
9695
9982
  * The P1 condition set — a flat AND of groups; absent group = pass;
9696
9983
  * membership lists are OR within the list (spec §2.3).
9697
9984
  */
9985
+ /**
9986
+ * What a rule may actuate.
9987
+ *
9988
+ * **No hand-maintained allowlist** (operator decision, and the right one — a
9989
+ * written list of methods is a third parallel map to keep aligned, and this
9990
+ * repo has paid for those). The boundary instead comes from a property the
9991
+ * capabilities already carry: an action may target only a **device-scoped**
9992
+ * capability method.
9993
+ *
9994
+ * That is not decoration. A rule can be authored by a NON-ADMIN — personal
9995
+ * rules are a supported flow — and the executor runs with the addon's
9996
+ * privileges, so an unbounded action is an arbitrary RPC channel with a
9997
+ * privilege escalation attached. Restricting to device scope excludes the
9998
+ * system caps (`device-manager.removeDevice` and friends) by construction,
9999
+ * costs nothing to maintain, and cannot rot: a cap that stops being
10000
+ * device-scoped stops being actuatable in the same change.
10001
+ *
10002
+ * The executor enforces it; {@link NcRuleActionSchema} carries the intent.
10003
+ */
10004
+ /**
10005
+ * One step of a sequence.
10006
+ *
10007
+ * `wait` is a first-class step rather than a property of the next action: it is
10008
+ * what makes a sequence a SEQUENCE and not a list — "unlock, wait 5s, open"
10009
+ * cannot be expressed otherwise.
10010
+ */
10011
+ var NcRuleActionSchema = discriminatedUnion("kind", [object({
10012
+ kind: literal("wait"),
10013
+ seconds: number().min(0).max(300)
10014
+ }), object({
10015
+ kind: literal("cap"),
10016
+ deviceId: number().int(),
10017
+ /** Capability name, e.g. `alarm-panel`. */
10018
+ cap: string().min(1),
10019
+ /** Method on it. The executor refuses a non-device-scoped cap. */
10020
+ method: string().min(1),
10021
+ /** Method arguments, minus `deviceId` (the executor injects it). */
10022
+ args: record(string(), unknown()).optional()
10023
+ })]);
10024
+ /**
10025
+ * A named, ordered run of steps with its own throttle.
10026
+ *
10027
+ * `minDelaySec` exists because a noisy rule otherwise hammers a physical
10028
+ * actuator — the rule's own cooldown governs NOTIFICATIONS, which is a
10029
+ * different budget from "how often may this gate actually open".
10030
+ */
10031
+ var NcRuleActionSequenceSchema = object({
10032
+ name: string().min(1).max(120),
10033
+ enabled: boolean(),
10034
+ minDelaySec: number().int().min(0).max(86400).optional(),
10035
+ actions: array(NcRuleActionSchema).min(1)
10036
+ });
10037
+ /**
10038
+ * One button carried by the notification, running a named sequence on tap.
10039
+ *
10040
+ * **Read this before adding a button that does something physical.** The tap
10041
+ * arrives over a link that travelled through third-party infrastructure — ntfy,
10042
+ * a push relay, whatever forwarded the message — and the callback's ONLY
10043
+ * authority is the token in that link: single-use, short-lived, bound to this
10044
+ * one action of this one notification. It does not identify who tapped.
10045
+ * Whoever holds the notification can run the button, once, inside the window.
10046
+ * That is the operator's explicit choice (2026-08-05), and `destructive` is a
10047
+ * rendering hint, not a second gate. [D47](decisions/adr-0047.md).
10048
+ */
10049
+ var NcRuleNotificationButtonSchema = object({
10050
+ /** Stable id — travels in the callback and identifies the button in logs. */
10051
+ id: string().min(1).max(64),
10052
+ label: string().min(1).max(40),
10053
+ /** Name of a sequence in `onTrigger`. The dispatcher drops a button whose
10054
+ * sequence does not exist rather than minting a token for nothing. */
10055
+ sequence: string().min(1).max(120),
10056
+ icon: NotificationActionIconSchema.optional(),
10057
+ destructive: boolean().optional()
10058
+ });
10059
+ /**
10060
+ * Sequences a rule runs, by hook point.
10061
+ *
10062
+ * ONLY `onTrigger` is here, deliberately. The reference also has activation /
10063
+ * deactivation / reset / post-generation hooks, and they are wanted — but this
10064
+ * repo's expensive failure mode is declaring a surface nothing produces, so a
10065
+ * hook appears here in the same change that produces its edge, never before.
10066
+ */
10067
+ var NcRuleActionsSchema = object({
10068
+ /** Runs when the rule MATCHES. */
10069
+ onTrigger: array(NcRuleActionSequenceSchema).optional(),
10070
+ /**
10071
+ * Buttons the NOTIFICATION carries, each running one of this rule's
10072
+ * sequences when tapped.
10073
+ *
10074
+ * Deliberately a REFERENCE to a sequence rather than a second place to
10075
+ * author steps. A button that could define its own actions would be a
10076
+ * parallel actuation vocabulary — the executor's device-scope check, the
10077
+ * stop-at-first-failure rule and the per-sequence throttle all live on
10078
+ * sequences, and a second authoring surface would drift from every one of
10079
+ * them.
10080
+ *
10081
+ * A sequence reachable ONLY by a button simply appears in `onTrigger` with
10082
+ * `enabled: false`: it is then authored, throttled and validated like the
10083
+ * rest, and nothing runs it automatically.
10084
+ */
10085
+ buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
10086
+ });
9698
10087
  var NcConditionsSchema = object({
10088
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
10089
+ deviceState: object({
10090
+ deviceId: number().int(),
10091
+ /** Any of these matches. */
10092
+ states: array(string().min(1)).min(1)
10093
+ }).optional(),
9699
10094
  /** Device scope — absent = all devices. */
9700
10095
  devices: array(number()).optional(),
9701
10096
  /** Detector class names (any overlap with the record's class set). */
@@ -9896,6 +10291,14 @@ var NcMediaPolicySchema = object({
9896
10291
  clipPreRollSec: number().int().min(0).max(30).optional(),
9897
10292
  clipPostRollSec: number().int().min(0).max(30).optional(),
9898
10293
  /**
10294
+ * Playback rate of the attached gif / clip. Absent = 2x.
10295
+ *
10296
+ * A notification clip is GLANCED at on a lock screen, not watched: at real
10297
+ * time an eight-second passage is eight seconds of the recipient's attention
10298
+ * and twice the bytes. 1 is real time for the operator who wants it.
10299
+ */
10300
+ clipSpeed: number().min(1).max(8).optional(),
10301
+ /**
9899
10302
  * Which stream profile the footage is cut from. Absent = the CHEAPEST
9900
10303
  * assigned profile: a notification is watched on a phone, so the 4K
9901
10304
  * rendition would burn CPU to produce a file the client downscales anyway.
@@ -9967,7 +10370,30 @@ var NcRuleInputSchema = object({
9967
10370
  * behaviour, visible to all, read-only in the viewer). Present = personal
9968
10371
  * rule owned by this userId. Server-stamped; never trusted from a client.
9969
10372
  */
9970
- ownerUserId: string().optional()
10373
+ ownerUserId: string().optional(),
10374
+ /**
10375
+ * May a non-admin snooze this rule for EVERYONE, not just themselves?
10376
+ *
10377
+ * A snooze is personal by default — it silences the person who set it. This
10378
+ * opts THIS rule into the "the gardener is here all afternoon" case, where
10379
+ * silencing the camera for the whole household is legitimate. It silences
10380
+ * other people, so it is off unless a rule deliberately allows it.
10381
+ *
10382
+ * `.optional()`, deliberately NOT `.default()`: a Zod default does not run on
10383
+ * the addon cap path (three production failures in one day), so absent is
10384
+ * read as `false` by {@link canSetGlobal} in the engine. Admins are not bound
10385
+ * by this flag — see the scope rules on that function.
10386
+ */
10387
+ snoozeAllowGlobal: boolean().optional(),
10388
+ /**
10389
+ * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
10390
+ *
10391
+ * This is what makes the rule set the alarm's trigger set without the alarm
10392
+ * being a special case: arming is
10393
+ * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
10394
+ * shape as every other actuation.
10395
+ */
10396
+ actions: NcRuleActionsSchema.optional()
9971
10397
  });
9972
10398
  /**
9973
10399
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -10038,7 +10464,8 @@ var NcConditionDescriptorSchema = object({
10038
10464
  "packagePhase",
10039
10465
  "crossingSelect",
10040
10466
  "polygonDraw",
10041
- "occupancy"
10467
+ "occupancy",
10468
+ "deviceState"
10042
10469
  ]),
10043
10470
  operator: _enum([
10044
10471
  "in",
@@ -10052,7 +10479,28 @@ var NcConditionDescriptorSchema = object({
10052
10479
  /** Which delivery kinds the condition applies to. */
10053
10480
  appliesTo: array(NcDeliverySchema),
10054
10481
  phase: string(),
10055
- description: string().optional()
10482
+ description: string().optional(),
10483
+ /**
10484
+ * The CHOICES for a single-choice widget (`sourceSelect`, `crossingSelect`,
10485
+ * `packagePhase`, …), served with the descriptor.
10486
+ *
10487
+ * Before this the descriptor said which widget to render and not what to put
10488
+ * in it, so every option list lived in three places: this file's enums, the
10489
+ * admin's `NC_*_OPTIONS` and the viewer's `NC_*_VALUES`. That triple mirror
10490
+ * is the drift that emptied the viewer's rule editor on 2026-08-04 — the app
10491
+ * mirrors the cap by hand, so it is only ever as current as its last build.
10492
+ *
10493
+ * With the options on the wire, a condition of an EXISTING `valueType` costs
10494
+ * zero client changes. Clients keep a local fallback for an older hub that
10495
+ * does not send them; absent here is "use your own list", not "no choices".
10496
+ */
10497
+ options: array(object({
10498
+ /** Written to the rule verbatim. `''` means the ABSENT state. */
10499
+ value: string(),
10500
+ label: string(),
10501
+ /** What THIS choice matches — shown one at a time, under the control. */
10502
+ hint: string().optional()
10503
+ })).readonly().optional()
10056
10504
  });
10057
10505
  /**
10058
10506
  * The delivery lifecycle status of a history row — a straight read of the
@@ -10137,6 +10585,74 @@ var NcHistoryFilterSchema = object({
10137
10585
  until: number().optional(),
10138
10586
  limit: number().int().min(1).max(500).default(100)
10139
10587
  });
10588
+ /**
10589
+ * What a snooze covers. Broader scopes win when several overlap, so one window
10590
+ * leaves ONE digest rather than a rule snooze and a whole-feed snooze both
10591
+ * summarising the same silence.
10592
+ */
10593
+ var NcSnoozeScopeSchema = _enum([
10594
+ "rule",
10595
+ "device",
10596
+ "all"
10597
+ ]);
10598
+ /**
10599
+ * Client-authored snooze. The server stamps `userId`, `startedAt` and
10600
+ * `expiresAt` — a DURATION is sent rather than an instant so a client with a
10601
+ * skewed clock cannot author a window that is already over, or never ends.
10602
+ */
10603
+ var NcSnoozeInputSchema = object({
10604
+ scope: NcSnoozeScopeSchema,
10605
+ /** Required when `scope: 'rule'` — a scoped snooze with no id matches
10606
+ * NOTHING rather than degrading to "everything". */
10607
+ ruleId: string().optional(),
10608
+ /** Required when `scope: 'device'`. */
10609
+ deviceId: number().int().optional(),
10610
+ durationMinutes: number().int().min(1).max(1440),
10611
+ /**
10612
+ * Silence this for EVERY recipient, not just the caller. Permission is
10613
+ * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
10614
+ * broader scopes). Absent = personal.
10615
+ */
10616
+ global: boolean().optional(),
10617
+ /**
10618
+ * Deliver a summary of what was suppressed when the window ends. Absent =
10619
+ * ON: someone silencing a nuisance camera wants it off, someone silencing a
10620
+ * SECURITY camera wants to know what they missed, and choosing "off" for
10621
+ * everybody is how a snooze becomes an outage. Resolved to a concrete
10622
+ * boolean by the server at create time — never left to a Zod default, which
10623
+ * does not run on the addon cap path.
10624
+ */
10625
+ summary: boolean().optional()
10626
+ });
10627
+ /** A persisted snooze window. */
10628
+ var NcSnoozeSchema = object({
10629
+ id: string(),
10630
+ /** Who set it. Also who it silences, unless `global`. */
10631
+ userId: string(),
10632
+ scope: NcSnoozeScopeSchema,
10633
+ ruleId: string().optional(),
10634
+ deviceId: number().int().optional(),
10635
+ startedAt: number(),
10636
+ /** Exclusive: at exactly this instant the snooze is over. Expiry is a
10637
+ * COMPARISON, not a job — no sweeper can leave the operator silenced. */
10638
+ expiresAt: number(),
10639
+ global: boolean(),
10640
+ summary: boolean(),
10641
+ /** When the end-of-window digest went out. Absent = not sent (yet, or the
10642
+ * window has not closed, or `summary` is false). */
10643
+ digestSentAt: number().optional()
10644
+ });
10645
+ object({
10646
+ snoozeId: string(),
10647
+ targetId: string(),
10648
+ ruleId: string(),
10649
+ ruleName: string(),
10650
+ deviceId: number().int(),
10651
+ /** How many notifications this snooze hid for that pair. */
10652
+ count: number().int(),
10653
+ firstAt: number(),
10654
+ lastAt: number()
10655
+ });
10140
10656
  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 }), {
10141
10657
  kind: "mutation",
10142
10658
  auth: "admin",
@@ -10166,7 +10682,13 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
10166
10682
  }), method(object({}), object({
10167
10683
  catalog: array(NcConditionDescriptorSchema),
10168
10684
  taxonomy: NcTaxonomySchema.optional()
10169
- })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
10685
+ })), 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 }), {
10686
+ kind: "mutation",
10687
+ caller: "required"
10688
+ }), method(object({ snoozeId: string() }), object({ success: literal(true) }), {
10689
+ kind: "mutation",
10690
+ caller: "required"
10691
+ });
10170
10692
  /**
10171
10693
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
10172
10694
  *
@@ -11186,7 +11708,15 @@ method(object({
11186
11708
  format: _enum(["gif", "mp4"]).default("gif"),
11187
11709
  maxWidth: number().int().min(120).max(1920).default(480),
11188
11710
  /** GIF only — MP4 keeps the source cadence. */
11189
- fps: number().int().min(1).max(15).default(5)
11711
+ fps: number().int().min(1).max(15).default(5),
11712
+ /**
11713
+ * Playback rate. A notification clip is GLANCED at on a lock screen,
11714
+ * not watched, so 2x is the default: the recipient sees the whole
11715
+ * passage in half the time and the GIF is half the bytes. `1` is real
11716
+ * time. Applies to MP4 as well — the operator set a speed, not a GIF
11717
+ * speed.
11718
+ */
11719
+ speed: number().min(1).max(8).default(2)
11190
11720
  }), object({
11191
11721
  base64: string(),
11192
11722
  mime: string(),
@@ -18312,6 +18842,20 @@ var GetInputSchema = object({ id: string() });
18312
18842
  var AddInputSchema = object({
18313
18843
  kind: string().min(1),
18314
18844
  name: string().min(1),
18845
+ /**
18846
+ * ADOPT an existing id instead of minting a new one.
18847
+ *
18848
+ * Written the day this cost an outage. A broker's id is not a detail: HA
18849
+ * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
18850
+ * lost from config and re-added as `ha_001` leaves every one of its devices
18851
+ * bound to a broker that no longer exists. Re-entering the password under the
18852
+ * ORIGINAL id turns a multi-step device migration back into re-entering a
18853
+ * password.
18854
+ *
18855
+ * A provider MUST refuse an id that is already in use — adopting a live
18856
+ * broker's id would silently take it over.
18857
+ */
18858
+ id: string().min(1).optional(),
18315
18859
  /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
18316
18860
  * `{baseUrl,accessToken}`). Validated by the kind-specific provider
18317
18861
  * branch on receipt — invalid shape rejects the add. */
@@ -19423,7 +19967,20 @@ method(object({
19423
19967
  }), _void(), {
19424
19968
  kind: "mutation",
19425
19969
  auth: "admin"
19426
- }), 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({
19970
+ }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({
19971
+ addonId: string().optional(),
19972
+ /**
19973
+ * `slim` omits `config` (returned `{}`), `metadata` (null) and the
19974
+ * `sourceInfo` derived from config — and skips the per-device settings
19975
+ * read that produces them. Everything identifying a device (id, name,
19976
+ * type, online, features, isCamera, parent/link ids) is unchanged.
19977
+ * Do not use it for dispatch routing, which needs `sourceInfo`.
19978
+ */
19979
+ projection: _enum(["full", "slim"]).optional(),
19980
+ /** Return only camera devices. Filtering server-side instead of
19981
+ * shipping 293 rows to find 12. */
19982
+ isCamera: boolean().optional()
19983
+ }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
19427
19984
  mode: DeviceLinkModeSchema,
19428
19985
  devices: array(LinkedDeviceSchema)
19429
19986
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
@@ -19862,14 +20419,14 @@ var LlmProfileSchema = object({
19862
20419
  /** ConfigUISchema tree passed through untyped on the wire (the
19863
20420
  * notification-output `ConfigSchemaPassthrough` precedent at
19864
20421
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
19865
- var ConfigSchemaPassthrough$1 = unknown();
20422
+ var ConfigSchemaPassthrough = unknown();
19866
20423
  var LlmProfileKindDescriptorSchema = object({
19867
20424
  kind: LlmProfileKindSchema,
19868
20425
  label: string(),
19869
20426
  icon: string(),
19870
20427
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
19871
20428
  addonId: string(),
19872
- configSchema: ConfigSchemaPassthrough$1
20429
+ configSchema: ConfigSchemaPassthrough
19873
20430
  });
19874
20431
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
19875
20432
  var LlmDefaultSchema = object({
@@ -20366,275 +20923,136 @@ var StatusSchema = object({
20366
20923
  embeddedRunning: boolean()
20367
20924
  });
20368
20925
  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);
20369
- var NetworkEndpointSchema = object({
20370
- url: string(),
20371
- hostname: string(),
20372
- port: number(),
20373
- protocol: _enum(["http", "https"])
20374
- });
20375
- var NetworkAccessStatusSchema = object({
20376
- connected: boolean(),
20377
- endpoint: NetworkEndpointSchema.nullable(),
20378
- error: string().optional()
20379
- });
20380
- /**
20381
- * Optional, richer endpoint shape returned by providers that expose
20382
- * MORE than one ingress concurrently (Tailscale Ingress with mixed
20383
- * serve+funnel rules, future ngrok multi-tunnel, …). Each entry carries
20384
- * the originating provider config (mode + sourcePort) so the
20385
- * orchestrator UI can label rows distinctly. Providers that expose only
20386
- * one endpoint just omit `listEndpoints` from their provider impl.
20387
- */
20388
- var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
20389
- /**
20390
- * Stable id within the provider — typically `<mode>-<sourcePort>` so
20391
- * the orchestrator can dedupe across `listEndpoints` polls.
20392
- */
20393
- id: string(),
20394
- /** Operator-facing label (mirrors `MeshEndpoint.label`). */
20395
- label: string(),
20396
- /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
20397
- mode: string().optional(),
20398
- /** Originating local port the ingress fronts (informational). */
20399
- sourcePort: number().optional()
20400
- });
20401
- method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
20402
- /**
20403
- * notification-output — canonical, capability-gated notification delivery.
20404
- *
20405
- * Apprise-derived model (see
20406
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
20407
- * callers emit ONE canonical `Notification`; each provider declares a
20408
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
20409
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
20410
- * message to what the kind supports — callers never special-case a service.
20411
- *
20412
- * DESIGN DECISIONS (locked):
20413
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
20414
- * `setTargetEnabled`), each provider persisting via the `settings-store`
20415
- * cap. Rationale: the admin UI needs one uniform surface across the
20416
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
20417
- * alternative would fork the UI per addon and cannot host the
20418
- * discovery→adopt flow.
20419
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
20420
- * the generated cap-mount auto-`concatCollection`-fans them across every
20421
- * registered provider (notifiers addon + HA addon) so one catalog is
20422
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
20423
- * `addonId` the generated collection router extracts from the call input.
20424
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
20425
- * transformer) + UDS MsgPack both round-trip typed arrays — already used by
20426
- * `storage` / `storage-provider` / `recording` caps over the same path. No
20427
- * base64 fallback needed.
20428
- *
20429
- * TODO (deferred, closed-set change — separate decision): add
20430
- * `providerKind: 'notify'` so notification providers surface on the unified
20431
- * admin "Integrations" page.
20432
- */
20433
- /**
20434
- * Zentik-derived typed-media enum — the superset across every kind. Each
20435
- * adapter picks what it supports and the degrade engine filters the rest.
20436
- */
20437
- var AttachmentMediaTypeSchema = _enum([
20438
- "image",
20439
- "video",
20440
- "gif",
20441
- "audio",
20442
- "icon"
20443
- ]);
20444
- /**
20445
- * A single attachment. Exactly one of `url` (remote source, most adapters
20446
- * prefer this) or `bytes` (inline source; required for Pushover-style
20447
- * bytes-only kinds) MUST be present — the degrade engine expresses a
20448
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
20449
- */
20450
- var AttachmentSchema = object({
20451
- mediaType: AttachmentMediaTypeSchema,
20452
- url: string().optional(),
20453
- bytes: _instanceof(Uint8Array).optional(),
20454
- mime: string().optional(),
20455
- name: string().optional()
20456
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
20457
- var NotificationFormatSchema = _enum([
20458
- "text",
20459
- "markdown",
20460
- "html"
20461
- ]);
20462
- /** A single tap-through action button. */
20463
- var NotificationActionSchema = object({
20464
- id: string(),
20465
- label: string(),
20466
- url: string().optional()
20467
- });
20468
- /**
20469
- * The canonical notification. `body` is the only hard field (Apprise model).
20470
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
20471
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
20472
- * the adapter maps this ordinal onto its native level. `level?` is an
20473
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
20474
- * `priority` for that one target.
20475
- */
20476
- var NotificationSchema = object({
20477
- body: string(),
20478
- title: string().optional(),
20479
- format: NotificationFormatSchema.default("text"),
20480
- priority: number().int().min(1).max(5).default(3),
20481
- level: string().optional(),
20482
- attachments: array(AttachmentSchema).optional(),
20483
- clickUrl: string().optional(),
20484
- actions: array(NotificationActionSchema).optional(),
20485
- sound: string().optional(),
20486
- ttl: number().optional(),
20487
- tag: string().optional(),
20488
- deviceId: number().optional(),
20489
- eventId: string().optional(),
20490
- metadata: record(string(), unknown()).optional()
20491
- });
20492
- /** One declared native severity/priority level for a kind. */
20493
- var TargetKindLevelSchema = object({
20494
- id: string(),
20495
- label: string(),
20496
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
20497
- ordinal: number().int().min(1).max(5).nullable(),
20498
- flags: object({
20499
- critical: boolean().optional(),
20500
- silent: boolean().optional(),
20501
- noPush: boolean().optional()
20502
- }).optional(),
20503
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
20504
- requires: array(string()).optional(),
20505
- description: string().optional()
20926
+ var NetworkEndpointSchema = object({
20927
+ url: string(),
20928
+ hostname: string(),
20929
+ port: number(),
20930
+ protocol: _enum(["http", "https"])
20506
20931
  });
20507
- /** The full capability block consulted before dispatch. */
20508
- var TargetKindCapsSchema = object({
20509
- attachments: object({
20510
- mediaTypes: array(AttachmentMediaTypeSchema),
20511
- mode: _enum([
20512
- "url",
20513
- "bytes",
20514
- "both"
20515
- ]),
20516
- max: number().int().nonnegative(),
20517
- maxBytes: number().int().positive().optional()
20518
- }),
20519
- /** Max action buttons (0 = none). */
20520
- actions: number().int().nonnegative(),
20521
- levels: array(TargetKindLevelSchema),
20522
- format: array(NotificationFormatSchema),
20523
- clickUrl: boolean(),
20524
- sound: boolean(),
20525
- ttl: boolean(),
20526
- bodyMaxLen: number().int().positive()
20932
+ var NetworkAccessStatusSchema = object({
20933
+ connected: boolean(),
20934
+ endpoint: NetworkEndpointSchema.nullable(),
20935
+ error: string().optional()
20527
20936
  });
20528
20937
  /**
20529
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
20530
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
20531
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`)
20532
- * the union is large and not meant for runtime validation here; the exported
20533
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
20938
+ * Optional, richer endpoint shape returned by providers that expose
20939
+ * MORE than one ingress concurrently (Tailscale Ingress with mixed
20940
+ * serve+funnel rules, future ngrok multi-tunnel, ). Each entry carries
20941
+ * the originating provider config (mode + sourcePort) so the
20942
+ * orchestrator UI can label rows distinctly. Providers that expose only
20943
+ * one endpoint just omit `listEndpoints` from their provider impl.
20534
20944
  */
20535
- var ConfigSchemaPassthrough = unknown();
20536
- var TargetKindSchema = object({
20537
- kind: string(),
20538
- label: string(),
20539
- icon: string(),
20540
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
20541
- addonId: string(),
20542
- /**
20543
- * URL of the kind's bundled BRAND icon, served by the providing addon over
20544
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
20545
- * when the addon bundles no icon for that kind — the client then falls back
20546
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
20547
- *
20548
- * Root-relative on purpose: it resolves against whatever origin serves a web
20549
- * client, and a native client joins it onto its own hub base.
20550
- *
20551
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
20552
- * field that survived only because the runtime cap-router forwards provider
20553
- * output verbatim — so every consumer had to re-declare it by hand to stop
20554
- * its own Zod parse from stripping it, and the whole arrangement would have
20555
- * broken silently the moment output validation was tightened anywhere.
20556
- */
20557
- iconUrl: string().optional(),
20945
+ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
20558
20946
  /**
20559
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
20560
- *
20561
- * The server knows this and therefore says it, because the client cannot
20562
- * safely guess: a React-Native client renders SVG and raster through two
20563
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
20564
- * not decode SVG on iOS/Android), so without this it silently fell back to a
20565
- * placeholder glyph for every vector icon while the web build looked fine.
20566
- *
20567
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
20568
- * not been updated — a client that cannot determine the type should prefer
20569
- * its raster path, which is the safe default for an unknown image.
20947
+ * Stable id within the provider typically `<mode>-<sourcePort>` so
20948
+ * the orchestrator can dedupe across `listEndpoints` polls.
20570
20949
  */
20571
- iconMediaType: string().optional(),
20572
- configSchema: ConfigSchemaPassthrough,
20573
- supportsDiscovery: boolean(),
20574
- caps: TargetKindCapsSchema
20950
+ id: string(),
20951
+ /** Operator-facing label (mirrors `MeshEndpoint.label`). */
20952
+ label: string(),
20953
+ /** Optional provider-specific mode tag, used for icon/colour in admin UI. */
20954
+ mode: string().optional(),
20955
+ /** Originating local port the ingress fronts (informational). */
20956
+ sourcePort: number().optional()
20575
20957
  });
20958
+ method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
20576
20959
  /**
20577
- * A persisted target. `config` holds secrets; providers REDACT secret fields
20578
- * (return a presence marker only) when serving `listTargets` — never
20579
- * round-trip a stored secret to the UI.
20580
- */
20581
- var TargetSchema = object({
20582
- id: string(),
20583
- name: string(),
20584
- kind: string(),
20585
- addonId: string(),
20960
+ * core-blocks user-authored TypeScript, stored in the kernel and executed in
20961
+ * its own process.
20962
+ *
20963
+ * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
20964
+ *
20965
+ * The first use is **owning devices without being a device provider**: a block
20966
+ * declares devices under a system or custom integration and drives their state,
20967
+ * with the same `ctx` an addon gets. Automations come later; nothing here
20968
+ * models a trigger.
20969
+ *
20970
+ * **Stated plainly, because it does not change by being true:** a block has an
20971
+ * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
20972
+ * with no review step. What makes that survivable is not a sandbox, it is
20973
+ * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
20974
+ * so a block that throws or never returns is marked `failed` and visible
20975
+ * instead of taking the hub with it (D6). Every method here is admin-only, and
20976
+ * must stay so.
20977
+ */
20978
+ /** Where a block runs. The operator chooses — a block driving a device on an
20979
+ * agent is the reason placement is not fixed to the hub. */
20980
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
20981
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
20982
+ * a failing block reads the same way a failing addon does. */
20983
+ var CoreBlockStatusSchema = _enum([
20984
+ "stopped",
20985
+ "starting",
20986
+ "running",
20987
+ "failed"
20988
+ ]);
20989
+ /** Client-authored fields. */
20990
+ var CoreBlockInputSchema = object({
20991
+ name: string().min(1).max(120),
20992
+ /** TypeScript source. Compiled server-side before it is ever stored — a
20993
+ * block that does not compile is a fork failure the operator would meet
20994
+ * minutes later, in a log, instead of in the editor. */
20995
+ code: string().max(2e5),
20586
20996
  enabled: boolean(),
20587
- config: record(string(), unknown())
20588
- });
20589
- /** A discovery-surfaced candidate (config is partial + non-secret). */
20590
- var DiscoveredTargetSchema = object({
20591
- kind: string(),
20592
- suggestedName: string(),
20593
- config: record(string(), unknown())
20997
+ placement: CoreBlockPlacementSchema,
20998
+ /**
20999
+ * Integration the block's devices hang from. Absent = the system integration
21000
+ * blocks share. A block may declare its own instead.
21001
+ */
21002
+ integrationId: string().optional()
20594
21003
  });
20595
- /** The degrade engine's report — what was resolved / dropped / degraded. */
20596
- var RenderedAsSchema = object({
20597
- level: string(),
20598
- format: NotificationFormatSchema,
20599
- attachmentsSent: number().int().nonnegative(),
20600
- actionsSent: number().int().nonnegative(),
20601
- truncated: boolean(),
20602
- dropped: array(string())
21004
+ /** A stored block. */
21005
+ var CoreBlockSchema = CoreBlockInputSchema.extend({
21006
+ id: string(),
21007
+ createdAt: number(),
21008
+ updatedAt: number(),
21009
+ /** Server-stamped author. */
21010
+ createdBy: string(),
21011
+ status: CoreBlockStatusSchema,
21012
+ /**
21013
+ * Why the block is not running, when it is not. The operator's ONLY window
21014
+ * into a block that failed at load — a block that is silently absent is the
21015
+ * failure mode this whole feature has to avoid.
21016
+ */
21017
+ lastError: string().optional(),
21018
+ /** Ms epoch of the last state change. */
21019
+ lastChangedAt: number()
20603
21020
  });
20604
- var SendResultSchema = object({
20605
- success: boolean(),
21021
+ /** What a compile attempt produced. */
21022
+ var CoreBlockCompileResultSchema = object({
21023
+ ok: boolean(),
21024
+ /** Present when `ok` is false — the first error, in the author's words. */
20606
21025
  error: string().optional(),
20607
- renderedAs: RenderedAsSchema.optional()
21026
+ line: number().optional(),
21027
+ column: number().optional()
20608
21028
  });
20609
- /** Same shape as SendResult kept as a distinct name for the test panel. */
20610
- var TestResultSchema = SendResultSchema;
20611
- var notificationOutputCapability = {
20612
- name: "notification-output",
20613
- scope: "system",
20614
- mode: "collection",
20615
- methods: {
20616
- listTargetKinds: method(object({}), array(TargetKindSchema)),
20617
- listTargets: method(object({}), array(TargetSchema)),
20618
- discoverTargets: method(object({
20619
- kind: string(),
20620
- config: record(string(), unknown()).optional()
20621
- }), array(DiscoveredTargetSchema)),
20622
- send: method(object({
20623
- targetId: string(),
20624
- notification: NotificationSchema
20625
- }), SendResultSchema, { kind: "mutation" }),
20626
- testTarget: method(object({
20627
- targetId: string(),
20628
- sample: NotificationSchema.optional()
20629
- }), TestResultSchema, { kind: "mutation" }),
20630
- upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
20631
- deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
20632
- setTargetEnabled: method(object({
20633
- targetId: string(),
20634
- enabled: boolean()
20635
- }), _void(), { kind: "mutation" })
20636
- }
20637
- };
21029
+ 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 }), {
21030
+ kind: "mutation",
21031
+ auth: "admin",
21032
+ caller: "required"
21033
+ }), method(object({
21034
+ blockId: string(),
21035
+ block: CoreBlockInputSchema.partial()
21036
+ }), object({ block: CoreBlockSchema }), {
21037
+ kind: "mutation",
21038
+ auth: "admin",
21039
+ caller: "required"
21040
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
21041
+ kind: "mutation",
21042
+ auth: "admin"
21043
+ }), method(object({
21044
+ blockId: string(),
21045
+ enabled: boolean()
21046
+ }), object({ block: CoreBlockSchema }), {
21047
+ kind: "mutation",
21048
+ auth: "admin"
21049
+ }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
21050
+ kind: "mutation",
21051
+ auth: "admin"
21052
+ }), method(object({}), object({ libs: array(object({
21053
+ filePath: string(),
21054
+ content: string()
21055
+ })) }), { auth: "admin" });
20638
21056
  /**
20639
21057
  * Zod schemas for persisted record types.
20640
21058
  *
@@ -20882,6 +21300,11 @@ var EventKindDescriptorSchema = object({
20882
21300
  deviceId: number()
20883
21301
  })
20884
21302
  });
21303
+ /** One camera's event vocabulary, as returned by `listEventKindsBatch`. */
21304
+ var EventKindsForDeviceSchema = object({
21305
+ deviceId: number(),
21306
+ kinds: array(EventKindDescriptorSchema).readonly()
21307
+ });
20885
21308
  var SensorEventSchema = object({
20886
21309
  id: string(),
20887
21310
  /** The CAMERA the event is attributed to (a sensor linked to N cameras
@@ -21138,6 +21561,19 @@ var MediaFileSchema = object({
21138
21561
  sizeBytes: number(),
21139
21562
  timestamp: number()
21140
21563
  });
21564
+ /**
21565
+ * One media row WITHOUT its bytes.
21566
+ *
21567
+ * A track's media is 5-8 MB of base64 (measured: 7.67 MB across 23 files for a
21568
+ * 140 s track), and a client that renders tiles from the media data plane needs
21569
+ * to know only WHAT EXISTS — the bytes then arrive per tile, lazily, over HTTP
21570
+ * with an immutable cache, instead of all at once inside a tRPC response that
21571
+ * blocks the whole view.
21572
+ *
21573
+ * `sizeBytes` is carried because it is what lets a client decide between the
21574
+ * stored blob and a `?variant=thumb` rendering without fetching either.
21575
+ */
21576
+ var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
21141
21577
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
21142
21578
  var MAX_EVENT_QUERY_LIMIT = 5e3;
21143
21579
  var DeviceEventQueryInput = object({
@@ -21287,7 +21723,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21287
21723
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
21288
21724
  kind: "mutation",
21289
21725
  auth: "admin"
21290
- }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({
21726
+ }), 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({
21291
21727
  deviceId: number(),
21292
21728
  since: number().optional(),
21293
21729
  until: number().optional(),
@@ -21361,7 +21797,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21361
21797
  }), array(MediaFileSchema).readonly()), method(object({
21362
21798
  trackId: string(),
21363
21799
  kinds: array(MediaFileKindEnum).optional()
21364
- }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
21800
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
21365
21801
  deviceId: number(),
21366
21802
  timestamp: number(),
21367
21803
  frameWidth: number(),
@@ -26040,6 +26476,54 @@ Object.freeze({
26040
26476
  addonId: null,
26041
26477
  access: "create"
26042
26478
  },
26479
+ "coreBlocks.compile": {
26480
+ capName: "core-blocks",
26481
+ capScope: "system",
26482
+ addonId: null,
26483
+ access: "create"
26484
+ },
26485
+ "coreBlocks.create": {
26486
+ capName: "core-blocks",
26487
+ capScope: "system",
26488
+ addonId: null,
26489
+ access: "create"
26490
+ },
26491
+ "coreBlocks.delete": {
26492
+ capName: "core-blocks",
26493
+ capScope: "system",
26494
+ addonId: null,
26495
+ access: "delete"
26496
+ },
26497
+ "coreBlocks.get": {
26498
+ capName: "core-blocks",
26499
+ capScope: "system",
26500
+ addonId: null,
26501
+ access: "view"
26502
+ },
26503
+ "coreBlocks.getTypeDefs": {
26504
+ capName: "core-blocks",
26505
+ capScope: "system",
26506
+ addonId: null,
26507
+ access: "view"
26508
+ },
26509
+ "coreBlocks.list": {
26510
+ capName: "core-blocks",
26511
+ capScope: "system",
26512
+ addonId: null,
26513
+ access: "view"
26514
+ },
26515
+ "coreBlocks.setEnabled": {
26516
+ capName: "core-blocks",
26517
+ capScope: "system",
26518
+ addonId: null,
26519
+ access: "create"
26520
+ },
26521
+ "coreBlocks.update": {
26522
+ capName: "core-blocks",
26523
+ capScope: "system",
26524
+ addonId: null,
26525
+ access: "create"
26526
+ },
26043
26527
  "cover.close": {
26044
26528
  capName: "cover",
26045
26529
  capScope: "device",
@@ -27948,12 +28432,24 @@ Object.freeze({
27948
28432
  addonId: null,
27949
28433
  access: "create"
27950
28434
  },
28435
+ "notificationRules.cancelSnooze": {
28436
+ capName: "notification-rules",
28437
+ capScope: "system",
28438
+ addonId: null,
28439
+ access: "create"
28440
+ },
27951
28441
  "notificationRules.createRule": {
27952
28442
  capName: "notification-rules",
27953
28443
  capScope: "system",
27954
28444
  addonId: null,
27955
28445
  access: "create"
27956
28446
  },
28447
+ "notificationRules.createSnooze": {
28448
+ capName: "notification-rules",
28449
+ capScope: "system",
28450
+ addonId: null,
28451
+ access: "create"
28452
+ },
27957
28453
  "notificationRules.deleteRule": {
27958
28454
  capName: "notification-rules",
27959
28455
  capScope: "system",
@@ -27984,6 +28480,12 @@ Object.freeze({
27984
28480
  addonId: null,
27985
28481
  access: "view"
27986
28482
  },
28483
+ "notificationRules.listSnoozes": {
28484
+ capName: "notification-rules",
28485
+ capScope: "system",
28486
+ addonId: null,
28487
+ access: "view"
28488
+ },
27987
28489
  "notificationRules.setRuleEnabled": {
27988
28490
  capName: "notification-rules",
27989
28491
  capScope: "system",
@@ -28188,6 +28690,12 @@ Object.freeze({
28188
28690
  addonId: null,
28189
28691
  access: "view"
28190
28692
  },
28693
+ "pipelineAnalytics.listEventKindsBatch": {
28694
+ capName: "pipeline-analytics",
28695
+ capScope: "device",
28696
+ addonId: null,
28697
+ access: "view"
28698
+ },
28191
28699
  "pipelineAnalytics.listOpsLog": {
28192
28700
  capName: "pipeline-analytics",
28193
28701
  capScope: "device",
@@ -28200,6 +28708,12 @@ Object.freeze({
28200
28708
  addonId: null,
28201
28709
  access: "view"
28202
28710
  },
28711
+ "pipelineAnalytics.listTrackMedia": {
28712
+ capName: "pipeline-analytics",
28713
+ capScope: "device",
28714
+ addonId: null,
28715
+ access: "view"
28716
+ },
28203
28717
  "pipelineAnalytics.listTracks": {
28204
28718
  capName: "pipeline-analytics",
28205
28719
  capScope: "device",
@@ -30670,11 +31184,18 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30670
31184
  async onActivate() {
30671
31185
  await super.onActivate();
30672
31186
  await this.refreshFeatures();
30673
- await this.subscribeBrokerEntity();
30674
- this.deviceLogger.info("HA entity ready", { meta: {
31187
+ if (await this.subscribeBrokerEntity()) {
31188
+ this.deviceLogger.info("HA entity ready", { meta: {
31189
+ entityId: this.entityId,
31190
+ type: this.ctx.deviceMeta.type,
31191
+ ...this.features.length > 0 ? { features: [...this.features] } : {}
31192
+ } });
31193
+ return;
31194
+ }
31195
+ this.deviceLogger.warn("HA entity registered but NOT subscribed — it will receive nothing", { meta: {
30675
31196
  entityId: this.entityId,
30676
- type: this.ctx.deviceMeta.type,
30677
- ...this.features.length > 0 ? { features: [...this.features] } : {}
31197
+ brokerId: this.brokerId,
31198
+ type: this.ctx.deviceMeta.type
30678
31199
  } });
30679
31200
  }
30680
31201
  async removeDevice() {
@@ -30778,6 +31299,8 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30778
31299
  * already-attached handler and `handleStatePush` fires with the real
30779
31300
  * current state (seeding the cap-state slice at construction time).
30780
31301
  */
31302
+ /** True when the entity is genuinely subscribed. The caller uses it to
31303
+ * decide whether "ready" is an honest thing to log. */
30781
31304
  async subscribeBrokerEntity() {
30782
31305
  try {
30783
31306
  const result = await this.ctx.api.broker.subscribe.mutate({
@@ -30785,12 +31308,14 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30785
31308
  filter: { entityIds: [this.entityId] }
30786
31309
  });
30787
31310
  this.brokerSubscriptionId = result.subscriptionId;
31311
+ return true;
30788
31312
  } catch (err) {
30789
31313
  this.deviceLogger.warn("ha-child failed to register broker subscription", { meta: {
30790
31314
  entityId: this.entityId,
30791
31315
  brokerId: this.brokerId,
30792
31316
  error: errMsg(err)
30793
31317
  } });
31318
+ return false;
30794
31319
  }
30795
31320
  }
30796
31321
  attachEventBusListener() {
@@ -35605,6 +36130,27 @@ var HaBrokerRegistry = class {
35605
36130
  this.nextIdCounter += 1;
35606
36131
  return id;
35607
36132
  }
36133
+ /**
36134
+ * Take a caller-supplied id, or refuse.
36135
+ *
36136
+ * Two refusals, and both matter more than they look:
36137
+ *
36138
+ * - **An id already in use** would SILENTLY take over a live broker: the new
36139
+ * entry replaces it in config while its devices keep pointing at the id,
36140
+ * now serving someone else's Home Assistant. Recovery is re-creating a
36141
+ * broker that is GONE, never overwriting one that is here.
36142
+ * - **The progressive counter is advanced past an adopted id.** Adopting
36143
+ * `ha_004` while the counter sits at 2 would let the next automatic
36144
+ * allocation mint `ha_004` a second time — two brokers, one id, and every
36145
+ * device bound to whichever won. The counter is only seeded from stored
36146
+ * entries at load, so it cannot discover this on its own.
36147
+ */
36148
+ adoptId(id) {
36149
+ if (this.managers.has(id)) throw new Error(`broker id "${id}" is already in use — adopt an id only to restore a broker that is gone`);
36150
+ const match = id.match(/^ha_(\d+)$/);
36151
+ if (match) this.nextIdCounter = Math.max(this.nextIdCounter, Number.parseInt(match[1], 10) + 1);
36152
+ return id;
36153
+ }
35608
36154
  seedCounterFromEntries(entries) {
35609
36155
  let maxSeen = 0;
35610
36156
  for (const e of entries) {
@@ -35624,7 +36170,7 @@ var HaBrokerRegistry = class {
35624
36170
  async createEntry(name, settings, options = {}) {
35625
36171
  const auth = settingsToAuth(settings);
35626
36172
  const entry = {
35627
- id: this.allocateId(),
36173
+ id: options.id !== void 0 ? this.adoptId(options.id) : this.allocateId(),
35628
36174
  name,
35629
36175
  auth,
35630
36176
  ...options.integrationId !== void 0 ? { integrationId: options.integrationId } : {}
@@ -36814,6 +37360,7 @@ function homeassistantTargetKind(addonId) {
36814
37360
  max: 1
36815
37361
  },
36816
37362
  actions: 3,
37363
+ actionIcons: true,
36817
37364
  levels: [
36818
37365
  {
36819
37366
  id: "default",
@@ -36869,6 +37416,21 @@ function applyLevel(data, level) {
36869
37416
  }
36870
37417
  data["push"] = push;
36871
37418
  }
37419
+ var HA_ACTION_ICONS = {
37420
+ acknowledge: "mdi:check",
37421
+ dismiss: "mdi:close",
37422
+ silence: "mdi:bell-off",
37423
+ view: "mdi:eye",
37424
+ play: "mdi:play",
37425
+ open: "mdi:door-open",
37426
+ close: "mdi:door-closed",
37427
+ lock: "mdi:lock",
37428
+ unlock: "mdi:lock-open",
37429
+ arm: "mdi:shield-check",
37430
+ disarm: "mdi:shield-off",
37431
+ light: "mdi:lightbulb",
37432
+ alert: "mdi:alert"
37433
+ };
36872
37434
  /**
36873
37435
  * Build the `notify.<service>` `service_data` from a prepared (degraded)
36874
37436
  * notification. PURE — no WS, unit-testable in isolation.
@@ -36885,7 +37447,8 @@ function buildNotifyServiceData(prepared) {
36885
37447
  if (prepared.actions.length > 0) data["actions"] = prepared.actions.map((a) => ({
36886
37448
  action: a.id,
36887
37449
  title: a.label,
36888
- ...a.url !== void 0 ? { uri: a.url } : {}
37450
+ ...a.url !== void 0 ? { uri: a.url } : {},
37451
+ ...a.icon !== void 0 ? { icon: HA_ACTION_ICONS[a.icon] } : {}
36889
37452
  }));
36890
37453
  if (prepared.clickUrl !== null) {
36891
37454
  data["url"] = prepared.clickUrl;
@@ -37229,6 +37792,19 @@ async function reconcileBroker(brokerId, deps) {
37229
37792
  * whose integration no longer exists should be cleaned up. Brokers with no
37230
37793
  * integrationId were created manually and are never auto-removed.
37231
37794
  */
37795
+ /**
37796
+ * Brokers whose spawning integration is gone.
37797
+ *
37798
+ * **The caller REPORTS these; it does not delete them.** This function used to
37799
+ * drive a delete, and on 2026-08-05 that erased the operator's Home Assistant
37800
+ * broker — host and token — when `integrations.list` returned an empty array
37801
+ * during a hub restart: nothing "survived", so everything was an orphan. A
37802
+ * broker cannot be rebuilt from an integration record, so the loss was
37803
+ * permanent and the doorbell simply stopped existing.
37804
+ *
37805
+ * Kept as a pure query because naming an orphan is useful; acting on one
37806
+ * automatically is not.
37807
+ */
37232
37808
  function computeBrokerCleanup(brokers, survivingIntegrationIds) {
37233
37809
  return brokers.filter((b) => b.integrationId !== void 0 && !survivingIntegrationIds.has(b.integrationId)).map((b) => b.id);
37234
37810
  }
@@ -37816,15 +38392,15 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
37816
38392
  }
37817
38393
  const linkedBrokers = linkBrokersToIntegrations(this.config.brokers, integrationsWithBrokerId);
37818
38394
  if (linkedBrokers.some((b, i) => b !== this.config.brokers[i])) await this.updateGlobalSettings({ brokers: linkedBrokers });
37819
- const toRemove = computeBrokerCleanup(this.config.brokers, survivingIntegrationIds);
37820
- if (toRemove.length === 0) return;
37821
- for (const id of toRemove) await this.requireRegistry().removeEntry(id);
37822
- const nextBrokers = this.config.brokers.filter((b) => !toRemove.includes(b.id));
37823
- await this.updateGlobalSettings({ brokers: nextBrokers });
38395
+ const orphans = computeBrokerCleanup(this.config.brokers, survivingIntegrationIds);
37824
38396
  this.ctx.logger.info("integration→broker reconcile", { meta: {
37825
- brokerCount: nextBrokers.length,
38397
+ brokerCount: this.config.brokers.length,
37826
38398
  integrationCount: haIntegrations.length,
37827
- removed: toRemove.length
38399
+ orphans: orphans.length
38400
+ } });
38401
+ if (orphans.length > 0) this.ctx.logger.warn("broker entries have no surviving integration — KEPT", { meta: {
38402
+ brokerIds: orphans,
38403
+ integrationCount: haIntegrations.length
37828
38404
  } });
37829
38405
  } catch (err) {
37830
38406
  this.ctx.logger.warn("integration→broker reconcile failed", { meta: { error: errMsg(err) } });
@@ -37958,9 +38534,13 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
37958
38534
  label: "Home Assistant"
37959
38535
  }]
37960
38536
  }],
37961
- add: async ({ kind, name, settings }) => {
38537
+ add: async ({ kind, name, settings, id }) => {
37962
38538
  if (kind !== HA_KIND) throw new Error(`provider-homeassistant: only kind '${HA_KIND}' is handled here (got '${kind}')`);
37963
- const entry = await this.requireRegistry().createEntry(name, settings);
38539
+ const entry = await this.requireRegistry().createEntry(name, settings, { ...id !== void 0 ? { id } : {} });
38540
+ if (id !== void 0) this.ctx.logger.info("broker restored under an adopted id", { meta: {
38541
+ brokerId: entry.id,
38542
+ name
38543
+ } });
37964
38544
  await this.updateGlobalSettings({ brokers: [...this.config.brokers, entry] });
37965
38545
  return { id: entry.id };
37966
38546
  },