@camstack/addon-provider-homeassistant 1.2.10 → 1.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +1140 -360
  2. package/dist/addon.mjs +1140 -360
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
3
- //#region ../types/dist/event-category-Bz24uP1U.mjs
3
+ //#region ../types/dist/event-category-41fKf-q9.mjs
4
4
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
5
5
  EventCategory["SystemBoot"] = "system.boot";
6
6
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -60,6 +60,26 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
60
60
  */
61
61
  EventCategory["AddonRetryScheduled"] = "addon.retry-scheduled";
62
62
  /**
63
+ * A liveness invariant of this node is false — it has no devices, every
64
+ * camera is offline, or a camera that was recording has stopped producing
65
+ * segments. Emitted by the `liveness-monitor` builtin, once per fault (the
66
+ * `findingId` is stable across ticks), only after its boot grace period.
67
+ * AlertCenter raises a persistent operator-visible alert.
68
+ *
69
+ * It exists because on 2026-08-03 the hub ran four hours with zero devices
70
+ * and zero recordings while every surface stayed quiet.
71
+ *
72
+ * Payload: `{ findingId, severity, title, message, deviceId? }`.
73
+ */
74
+ EventCategory["SystemLivenessFailed"] = "system.liveness-failed";
75
+ /**
76
+ * A previously reported liveness fault is true again. AlertCenter dismisses
77
+ * the matching `SystemLivenessFailed` alert.
78
+ *
79
+ * Payload: `{ findingId }`.
80
+ */
81
+ EventCategory["SystemLivenessRecovered"] = "system.liveness-recovered";
82
+ /**
63
83
  * Monitor is attempting to reload a failed addon NOW. UI uses this
64
84
  * to show a spinner during the retry attempt. Same transient nature
65
85
  * as AddonRetryScheduled.
@@ -7443,14 +7463,7 @@ var RecordingConfigSchema = object({
7443
7463
  * windows only — existing sheets are immutable, and each window's index
7444
7464
  * carries its own tile dims so mixed-preset history renders correctly.
7445
7465
  */
7446
- scrubThumbnails: ScrubThumbnailPresetSchema.optional(),
7447
- /**
7448
- * OPT-IN thumbnail-strip generation for this camera: every keyframe of the
7449
- * low recording saved as a JPEG (the fast-drag scrub depth), a derived
7450
- * cache that eviction reclaims with the footage. Absent/false = no strips
7451
- * are written and scrub reads exact keyframes at every velocity.
7452
- */
7453
- stripsEnabled: boolean().optional()
7466
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7454
7467
  }).strict();
7455
7468
  /**
7456
7469
  * Ops-log — the durable, append-only operations audit shared by the
@@ -7512,7 +7525,7 @@ var OpsLogQueryInputSchema = object({
7512
7525
  /**
7513
7526
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7514
7527
  *
7515
- * One shape shared by the recorder's `relocateFootage` (segments + strips) and
7528
+ * One shape shared by the recorder's `relocateFootage` (segments) and
7516
7529
  * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7517
7530
  * page renders both movers with one component. Jobs are in-RAM (a restart
7518
7531
  * forgets them — re-running is safe by construction: copy-if-absent, delete
@@ -7534,7 +7547,7 @@ var RelocateJobSchema = object({
7534
7547
  toLocationId: string(),
7535
7548
  /** Scoped device, or null = every device. */
7536
7549
  deviceId: number().nullable(),
7537
- /** What the job moves (owner-addon specific: segments/strips or media). */
7550
+ /** What the job moves (owner-addon specific: segments or media). */
7538
7551
  entities: array(string()),
7539
7552
  filesMoved: number().int(),
7540
7553
  bytesMoved: number().int(),
@@ -7548,7 +7561,7 @@ var RelocateFootageInputSchema = object({
7548
7561
  deviceId: number().optional(),
7549
7562
  fromLocationId: string(),
7550
7563
  toLocationId: string(),
7551
- entities: array(_enum(["segments", "strips"])).optional(),
7564
+ entities: array(_enum(["segments"])).optional(),
7552
7565
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7553
7566
  * never allowed to starve live writers. */
7554
7567
  throttleMbps: number().min(1).max(1e3).optional()
@@ -9453,8 +9466,11 @@ function prepareNotification(caps, n) {
9453
9466
  });
9454
9467
  }
9455
9468
  const inActions = n.actions ?? [];
9456
- const actions = inActions.slice(0, Math.max(0, caps.actions));
9457
- 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);
9458
9474
  let clickUrl = null;
9459
9475
  if (n.clickUrl !== void 0) if (caps.clickUrl) clickUrl = n.clickUrl;
9460
9476
  else dropped.push("clickUrl");
@@ -9553,6 +9569,290 @@ var MaskGridDimsSchema = object({
9553
9569
  height: number()
9554
9570
  });
9555
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
+ /**
9556
9856
  * notification-rules — the Notification Center rule surface (P1 core).
9557
9857
  *
9558
9858
  * Spec: `docs/superpowers/specs/2026-07-22-notification-center-requirements.md`
@@ -9682,7 +9982,115 @@ var NcZoneConditionSchema = object({
9682
9982
  * The P1 condition set — a flat AND of groups; absent group = pass;
9683
9983
  * membership lists are OR within the list (spec §2.3).
9684
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
+ });
9685
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(),
9686
10094
  /** Device scope — absent = all devices. */
9687
10095
  devices: array(number()).optional(),
9688
10096
  /** Detector class names (any overlap with the record's class set). */
@@ -9883,6 +10291,14 @@ var NcMediaPolicySchema = object({
9883
10291
  clipPreRollSec: number().int().min(0).max(30).optional(),
9884
10292
  clipPostRollSec: number().int().min(0).max(30).optional(),
9885
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
+ /**
9886
10302
  * Which stream profile the footage is cut from. Absent = the CHEAPEST
9887
10303
  * assigned profile: a notification is watched on a phone, so the 4K
9888
10304
  * rendition would burn CPU to produce a file the client downscales anyway.
@@ -9954,7 +10370,30 @@ var NcRuleInputSchema = object({
9954
10370
  * behaviour, visible to all, read-only in the viewer). Present = personal
9955
10371
  * rule owned by this userId. Server-stamped; never trusted from a client.
9956
10372
  */
9957
- 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()
9958
10397
  });
9959
10398
  /**
9960
10399
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -10025,7 +10464,8 @@ var NcConditionDescriptorSchema = object({
10025
10464
  "packagePhase",
10026
10465
  "crossingSelect",
10027
10466
  "polygonDraw",
10028
- "occupancy"
10467
+ "occupancy",
10468
+ "deviceState"
10029
10469
  ]),
10030
10470
  operator: _enum([
10031
10471
  "in",
@@ -10039,7 +10479,28 @@ var NcConditionDescriptorSchema = object({
10039
10479
  /** Which delivery kinds the condition applies to. */
10040
10480
  appliesTo: array(NcDeliverySchema),
10041
10481
  phase: string(),
10042
- 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()
10043
10504
  });
10044
10505
  /**
10045
10506
  * The delivery lifecycle status of a history row — a straight read of the
@@ -10124,6 +10585,74 @@ var NcHistoryFilterSchema = object({
10124
10585
  until: number().optional(),
10125
10586
  limit: number().int().min(1).max(500).default(100)
10126
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
+ });
10127
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 }), {
10128
10657
  kind: "mutation",
10129
10658
  auth: "admin",
@@ -10153,7 +10682,13 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
10153
10682
  }), method(object({}), object({
10154
10683
  catalog: array(NcConditionDescriptorSchema),
10155
10684
  taxonomy: NcTaxonomySchema.optional()
10156
- })), 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
+ });
10157
10692
  /**
10158
10693
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
10159
10694
  *
@@ -11173,7 +11708,15 @@ method(object({
11173
11708
  format: _enum(["gif", "mp4"]).default("gif"),
11174
11709
  maxWidth: number().int().min(120).max(1920).default(480),
11175
11710
  /** GIF only — MP4 keeps the source cadence. */
11176
- 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)
11177
11720
  }), object({
11178
11721
  base64: string(),
11179
11722
  mime: string(),
@@ -18511,6 +19054,210 @@ method(object({ codec: string() }), boolean()), method(_void(), object({
18511
19054
  kind: "mutation",
18512
19055
  auth: "admin"
18513
19056
  });
19057
+ /**
19058
+ * Query filter for settings-store collections.
19059
+ */
19060
+ var QueryFilterSchema = object({
19061
+ where: record(string(), unknown()).optional(),
19062
+ whereIn: record(string(), array(unknown())).optional(),
19063
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
19064
+ orderBy: object({
19065
+ field: string(),
19066
+ direction: _enum(["asc", "desc"])
19067
+ }).optional(),
19068
+ limit: number().optional(),
19069
+ offset: number().optional()
19070
+ });
19071
+ /**
19072
+ * The predicate half of a filter, for BULK MUTATIONS.
19073
+ *
19074
+ * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
19075
+ * meaning for a statement that rewrites a set, and accepting them would invite
19076
+ * a caller to believe `limit` bounds the damage. Every field is optional here
19077
+ * only so the shape stays composable — the implementation REJECTS a filter
19078
+ * that compiles to no predicate, because that is the whole collection.
19079
+ */
19080
+ var MutationFilterSchema = object({
19081
+ where: record(string(), unknown()).optional(),
19082
+ whereIn: record(string(), array(unknown())).optional(),
19083
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional()
19084
+ });
19085
+ /** A single stored record: `{ id, data }`. */
19086
+ var SettingsRecordSchema = object({
19087
+ id: string(),
19088
+ data: record(string(), unknown())
19089
+ });
19090
+ /**
19091
+ * Column declaration for a structured (SQL-backed) collection.
19092
+ *
19093
+ * Logical types — the backend translates each to the matching SQLite
19094
+ * storage class and handles per-type marshaling:
19095
+ * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
19096
+ * - `JSON` — TEXT under the hood; serialised on write, parsed on read
19097
+ * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
19098
+ */
19099
+ var CollectionColumnSchema = object({
19100
+ name: string(),
19101
+ type: _enum([
19102
+ "TEXT",
19103
+ "INTEGER",
19104
+ "REAL",
19105
+ "JSON",
19106
+ "BOOLEAN"
19107
+ ]),
19108
+ primaryKey: boolean().optional(),
19109
+ notNull: boolean().optional(),
19110
+ unique: boolean().optional(),
19111
+ /**
19112
+ * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
19113
+ * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
19114
+ * collection surface simply cannot express their existing tables — which is
19115
+ * why they were still on the uncapped `table*` API.
19116
+ */
19117
+ defaultValue: union([
19118
+ string(),
19119
+ number(),
19120
+ boolean()
19121
+ ]).optional()
19122
+ });
19123
+ var CollectionIndexSchema = object({
19124
+ name: string(),
19125
+ columns: array(string()).readonly(),
19126
+ unique: boolean().optional()
19127
+ });
19128
+ method(object({
19129
+ namespace: string().optional(),
19130
+ collection: string(),
19131
+ key: string()
19132
+ }), unknown()), method(object({
19133
+ namespace: string().optional(),
19134
+ collection: string(),
19135
+ key: string(),
19136
+ value: unknown()
19137
+ }), _void(), { kind: "mutation" }), method(object({
19138
+ namespace: string().optional(),
19139
+ collection: string(),
19140
+ filter: QueryFilterSchema.optional()
19141
+ }), array(SettingsRecordSchema).readonly()), method(object({
19142
+ namespace: string().optional(),
19143
+ collection: string(),
19144
+ record: SettingsRecordSchema
19145
+ }), _void(), { kind: "mutation" }), method(object({
19146
+ namespace: string().optional(),
19147
+ collection: string(),
19148
+ id: string(),
19149
+ data: record(string(), unknown())
19150
+ }), _void(), { kind: "mutation" }), method(object({
19151
+ namespace: string().optional(),
19152
+ collection: string(),
19153
+ key: string()
19154
+ }), _void(), { kind: "mutation" }), method(object({
19155
+ namespace: string().optional(),
19156
+ collection: string(),
19157
+ filter: MutationFilterSchema
19158
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
19159
+ namespace: string().optional(),
19160
+ collection: string(),
19161
+ filter: MutationFilterSchema,
19162
+ data: record(string(), unknown())
19163
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
19164
+ namespace: string().optional(),
19165
+ collection: string(),
19166
+ filter: QueryFilterSchema.optional()
19167
+ }), number()), method(object({
19168
+ namespace: string().optional(),
19169
+ collection: string(),
19170
+ field: string(),
19171
+ bucketSize: number().int().positive(),
19172
+ origin: number().int(),
19173
+ filter: QueryFilterSchema.optional()
19174
+ }), array(object({
19175
+ bucket: number().int(),
19176
+ count: number().int()
19177
+ })).readonly()), method(object({
19178
+ namespace: string().optional(),
19179
+ collection: string()
19180
+ }), boolean()), method(object({
19181
+ namespace: string().optional(),
19182
+ collection: string(),
19183
+ columns: array(CollectionColumnSchema).readonly(),
19184
+ indexes: array(CollectionIndexSchema).readonly().optional()
19185
+ }), _void(), { kind: "mutation" });
19186
+ /**
19187
+ * What one engine says about itself. The orchestrator uses `kind` to pick
19188
+ * a registrant for a collection; `engineId` is what a log line names when
19189
+ * a call is routed or refused.
19190
+ */
19191
+ var EngineInfoSchema = object({
19192
+ engineId: string(),
19193
+ /**
19194
+ * `relational` — rows, columns, indexes, the surface `settings-store`
19195
+ * has always described. `vector` — an embedding store answering
19196
+ * similarity queries. A registrant declares exactly one; an engine that
19197
+ * does both registers twice, because "both" would make the routing
19198
+ * decision ambiguous at exactly the point it must not be.
19199
+ */
19200
+ kind: _enum(["relational", "vector"]),
19201
+ displayName: string()
19202
+ });
19203
+ method(_void(), EngineInfoSchema), method(object({
19204
+ namespace: string().optional(),
19205
+ collection: string(),
19206
+ key: string()
19207
+ }), unknown()), method(object({
19208
+ namespace: string().optional(),
19209
+ collection: string(),
19210
+ key: string(),
19211
+ value: unknown()
19212
+ }), _void(), { kind: "mutation" }), method(object({
19213
+ namespace: string().optional(),
19214
+ collection: string(),
19215
+ filter: QueryFilterSchema.optional()
19216
+ }), array(SettingsRecordSchema).readonly()), method(object({
19217
+ namespace: string().optional(),
19218
+ collection: string(),
19219
+ record: SettingsRecordSchema
19220
+ }), _void(), { kind: "mutation" }), method(object({
19221
+ namespace: string().optional(),
19222
+ collection: string(),
19223
+ id: string(),
19224
+ data: record(string(), unknown())
19225
+ }), _void(), { kind: "mutation" }), method(object({
19226
+ namespace: string().optional(),
19227
+ collection: string(),
19228
+ key: string()
19229
+ }), _void(), { kind: "mutation" }), method(object({
19230
+ namespace: string().optional(),
19231
+ collection: string(),
19232
+ filter: MutationFilterSchema
19233
+ }), object({ deleted: number().int() }), { kind: "mutation" }), method(object({
19234
+ namespace: string().optional(),
19235
+ collection: string(),
19236
+ filter: MutationFilterSchema,
19237
+ data: record(string(), unknown())
19238
+ }), object({ updated: number().int() }), { kind: "mutation" }), method(object({
19239
+ namespace: string().optional(),
19240
+ collection: string(),
19241
+ filter: QueryFilterSchema.optional()
19242
+ }), number()), method(object({
19243
+ namespace: string().optional(),
19244
+ collection: string(),
19245
+ field: string(),
19246
+ bucketSize: number().int().positive(),
19247
+ origin: number().int(),
19248
+ filter: QueryFilterSchema.optional()
19249
+ }), array(object({
19250
+ bucket: number().int(),
19251
+ count: number().int()
19252
+ })).readonly()), method(object({
19253
+ namespace: string().optional(),
19254
+ collection: string()
19255
+ }), boolean()), method(object({
19256
+ namespace: string().optional(),
19257
+ collection: string(),
19258
+ columns: array(CollectionColumnSchema).readonly(),
19259
+ indexes: array(CollectionIndexSchema).readonly().optional()
19260
+ }), _void(), { kind: "mutation" });
18514
19261
  DeviceType.Camera;
18515
19262
  /**
18516
19263
  * `device-adoption` — generic discovery + adoption surface,
@@ -19206,7 +19953,20 @@ method(object({
19206
19953
  }), _void(), {
19207
19954
  kind: "mutation",
19208
19955
  auth: "admin"
19209
- }), 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({
19956
+ }), method(object({ addonId: string() }), array(SavedDeviceRowSchema)), method(object({
19957
+ addonId: string().optional(),
19958
+ /**
19959
+ * `slim` omits `config` (returned `{}`), `metadata` (null) and the
19960
+ * `sourceInfo` derived from config — and skips the per-device settings
19961
+ * read that produces them. Everything identifying a device (id, name,
19962
+ * type, online, features, isCamera, parent/link ids) is unchanged.
19963
+ * Do not use it for dispatch routing, which needs `sourceInfo`.
19964
+ */
19965
+ projection: _enum(["full", "slim"]).optional(),
19966
+ /** Return only camera devices. Filtering server-side instead of
19967
+ * shipping 293 rows to find 12. */
19968
+ isCamera: boolean().optional()
19969
+ }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
19210
19970
  mode: DeviceLinkModeSchema,
19211
19971
  devices: array(LinkedDeviceSchema)
19212
19972
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
@@ -19645,14 +20405,14 @@ var LlmProfileSchema = object({
19645
20405
  /** ConfigUISchema tree passed through untyped on the wire (the
19646
20406
  * notification-output `ConfigSchemaPassthrough` precedent at
19647
20407
  * notification-output.cap.ts:151); the exported TS type re-tightens it. */
19648
- var ConfigSchemaPassthrough$1 = unknown();
20408
+ var ConfigSchemaPassthrough = unknown();
19649
20409
  var LlmProfileKindDescriptorSchema = object({
19650
20410
  kind: LlmProfileKindSchema,
19651
20411
  label: string(),
19652
20412
  icon: string(),
19653
20413
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
19654
20414
  addonId: string(),
19655
- configSchema: ConfigSchemaPassthrough$1
20415
+ configSchema: ConfigSchemaPassthrough
19656
20416
  });
19657
20417
  var LlmDefaultSelectorSchema = union([object({ consumer: string() }), object({ purpose: _enum(["text", "vision"]) })]);
19658
20418
  var LlmDefaultSchema = object({
@@ -20183,241 +20943,102 @@ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
20183
20943
  });
20184
20944
  method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
20185
20945
  /**
20186
- * notification-outputcanonical, capability-gated notification delivery.
20187
- *
20188
- * Apprise-derived model (see
20189
- * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
20190
- * callers emit ONE canonical `Notification`; each provider declares a
20191
- * per-kind capability descriptor (`TargetKind`), and the pure degrade
20192
- * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
20193
- * message to what the kind supports callers never special-case a service.
20194
- *
20195
- * DESIGN DECISIONS (locked):
20196
- * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
20197
- * `setTargetEnabled`), each provider persisting via the `settings-store`
20198
- * cap. Rationale: the admin UI needs one uniform surface across the
20199
- * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
20200
- * alternative would fork the UI per addon and cannot host the
20201
- * discovery→adopt flow.
20202
- * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
20203
- * the generated cap-mount auto-`concatCollection`-fans them across every
20204
- * registered provider (notifiers addon + HA addon) so one catalog is
20205
- * routable. `send` / `testTarget` / CRUD route to ONE provider by the
20206
- * `addonId` the generated collection router extracts from the call input.
20207
- * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
20208
- * transformer) + UDS MsgPack both round-trip typed arrays already used by
20209
- * `storage` / `storage-provider` / `recording` caps over the same path. No
20210
- * base64 fallback needed.
20211
- *
20212
- * TODO (deferred, closed-set change — separate decision): add
20213
- * `providerKind: 'notify'` so notification providers surface on the unified
20214
- * admin "Integrations" page.
20215
- */
20216
- /**
20217
- * Zentik-derived typed-media enum — the superset across every kind. Each
20218
- * adapter picks what it supports and the degrade engine filters the rest.
20219
- */
20220
- var AttachmentMediaTypeSchema = _enum([
20221
- "image",
20222
- "video",
20223
- "gif",
20224
- "audio",
20225
- "icon"
20226
- ]);
20227
- /**
20228
- * A single attachment. Exactly one of `url` (remote source, most adapters
20229
- * prefer this) or `bytes` (inline source; required for Pushover-style
20230
- * bytes-only kinds) MUST be present — the degrade engine expresses a
20231
- * url→bytes fetch as a `needsFetch` directive the adapter executes.
20232
- */
20233
- var AttachmentSchema = object({
20234
- mediaType: AttachmentMediaTypeSchema,
20235
- url: string().optional(),
20236
- bytes: _instanceof(Uint8Array).optional(),
20237
- mime: string().optional(),
20238
- name: string().optional()
20239
- }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
20240
- var NotificationFormatSchema = _enum([
20241
- "text",
20242
- "markdown",
20243
- "html"
20946
+ * core-blocksuser-authored TypeScript, stored in the kernel and executed in
20947
+ * its own process.
20948
+ *
20949
+ * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
20950
+ *
20951
+ * The first use is **owning devices without being a device provider**: a block
20952
+ * declares devices under a system or custom integration and drives their state,
20953
+ * with the same `ctx` an addon gets. Automations come later; nothing here
20954
+ * models a trigger.
20955
+ *
20956
+ * **Stated plainly, because it does not change by being true:** a block has an
20957
+ * addon's powers devices, storage, the event bus, `ctx.api`. It is a plugin
20958
+ * with no review step. What makes that survivable is not a sandbox, it is
20959
+ * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
20960
+ * so a block that throws or never returns is marked `failed` and visible
20961
+ * instead of taking the hub with it (D6). Every method here is admin-only, and
20962
+ * must stay so.
20963
+ */
20964
+ /** Where a block runs. The operator chooses a block driving a device on an
20965
+ * agent is the reason placement is not fixed to the hub. */
20966
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
20967
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
20968
+ * a failing block reads the same way a failing addon does. */
20969
+ var CoreBlockStatusSchema = _enum([
20970
+ "stopped",
20971
+ "starting",
20972
+ "running",
20973
+ "failed"
20244
20974
  ]);
20245
- /** A single tap-through action button. */
20246
- var NotificationActionSchema = object({
20247
- id: string(),
20248
- label: string(),
20249
- url: string().optional()
20250
- });
20251
- /**
20252
- * The canonical notification. `body` is the only hard field (Apprise model).
20253
- * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
20254
- * NOT a fixed severity enum — each kind declares its own `caps.levels` and
20255
- * the adapter maps this ordinal onto its native level. `level?` is an
20256
- * optional kind-native level id (`emergency`, `silent`, …) that overrides
20257
- * `priority` for that one target.
20258
- */
20259
- var NotificationSchema = object({
20260
- body: string(),
20261
- title: string().optional(),
20262
- format: NotificationFormatSchema.default("text"),
20263
- priority: number().int().min(1).max(5).default(3),
20264
- level: string().optional(),
20265
- attachments: array(AttachmentSchema).optional(),
20266
- clickUrl: string().optional(),
20267
- actions: array(NotificationActionSchema).optional(),
20268
- sound: string().optional(),
20269
- ttl: number().optional(),
20270
- tag: string().optional(),
20271
- deviceId: number().optional(),
20272
- eventId: string().optional(),
20273
- metadata: record(string(), unknown()).optional()
20274
- });
20275
- /** One declared native severity/priority level for a kind. */
20276
- var TargetKindLevelSchema = object({
20277
- id: string(),
20278
- label: string(),
20279
- /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
20280
- ordinal: number().int().min(1).max(5).nullable(),
20281
- flags: object({
20282
- critical: boolean().optional(),
20283
- silent: boolean().optional(),
20284
- noPush: boolean().optional()
20285
- }).optional(),
20286
- /** e.g. Pushover `emergency` requires `retry` / `expire`. */
20287
- requires: array(string()).optional(),
20288
- description: string().optional()
20289
- });
20290
- /** The full capability block consulted before dispatch. */
20291
- var TargetKindCapsSchema = object({
20292
- attachments: object({
20293
- mediaTypes: array(AttachmentMediaTypeSchema),
20294
- mode: _enum([
20295
- "url",
20296
- "bytes",
20297
- "both"
20298
- ]),
20299
- max: number().int().nonnegative(),
20300
- maxBytes: number().int().positive().optional()
20301
- }),
20302
- /** Max action buttons (0 = none). */
20303
- actions: number().int().nonnegative(),
20304
- levels: array(TargetKindLevelSchema),
20305
- format: array(NotificationFormatSchema),
20306
- clickUrl: boolean(),
20307
- sound: boolean(),
20308
- ttl: boolean(),
20309
- bodyMaxLen: number().int().positive()
20310
- });
20311
- /**
20312
- * `configSchema` is a `ConfigUISchema` tree passed through to the admin
20313
- * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
20314
- * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
20315
- * the union is large and not meant for runtime validation here; the exported
20316
- * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
20317
- */
20318
- var ConfigSchemaPassthrough = unknown();
20319
- var TargetKindSchema = object({
20320
- kind: string(),
20321
- label: string(),
20322
- icon: string(),
20323
- /** Stamped by each provider so the concat-fanned catalog stays routable. */
20324
- addonId: string(),
20325
- /**
20326
- * URL of the kind's bundled BRAND icon, served by the providing addon over
20327
- * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
20328
- * when the addon bundles no icon for that kind — the client then falls back
20329
- * to a neutral glyph rather than rendering the raw `icon` NAME as text.
20330
- *
20331
- * Root-relative on purpose: it resolves against whatever origin serves a web
20332
- * client, and a native client joins it onto its own hub base.
20333
- *
20334
- * DECLARED here deliberately. It used to travel as an undeclared passthrough
20335
- * field that survived only because the runtime cap-router forwards provider
20336
- * output verbatim — so every consumer had to re-declare it by hand to stop
20337
- * its own Zod parse from stripping it, and the whole arrangement would have
20338
- * broken silently the moment output validation was tightened anywhere.
20339
- */
20340
- iconUrl: string().optional(),
20975
+ /** Client-authored fields. */
20976
+ var CoreBlockInputSchema = object({
20977
+ name: string().min(1).max(120),
20978
+ /** TypeScript source. Compiled server-side before it is ever stored — a
20979
+ * block that does not compile is a fork failure the operator would meet
20980
+ * minutes later, in a log, instead of in the editor. */
20981
+ code: string().max(2e5),
20982
+ enabled: boolean(),
20983
+ placement: CoreBlockPlacementSchema,
20341
20984
  /**
20342
- * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
20343
- *
20344
- * The server knows this and therefore says it, because the client cannot
20345
- * safely guess: a React-Native client renders SVG and raster through two
20346
- * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
20347
- * not decode SVG on iOS/Android), so without this it silently fell back to a
20348
- * placeholder glyph for every vector icon while the web build looked fine.
20349
- *
20350
- * Absent when {@link iconUrl} is absent, or for a legacy provider that has
20351
- * not been updated — a client that cannot determine the type should prefer
20352
- * its raster path, which is the safe default for an unknown image.
20985
+ * Integration the block's devices hang from. Absent = the system integration
20986
+ * blocks share. A block may declare its own instead.
20353
20987
  */
20354
- iconMediaType: string().optional(),
20355
- configSchema: ConfigSchemaPassthrough,
20356
- supportsDiscovery: boolean(),
20357
- caps: TargetKindCapsSchema
20988
+ integrationId: string().optional()
20358
20989
  });
20359
- /**
20360
- * A persisted target. `config` holds secrets; providers REDACT secret fields
20361
- * (return a presence marker only) when serving `listTargets` — never
20362
- * round-trip a stored secret to the UI.
20363
- */
20364
- var TargetSchema = object({
20990
+ /** A stored block. */
20991
+ var CoreBlockSchema = CoreBlockInputSchema.extend({
20365
20992
  id: string(),
20366
- name: string(),
20367
- kind: string(),
20368
- addonId: string(),
20369
- enabled: boolean(),
20370
- config: record(string(), unknown())
20371
- });
20372
- /** A discovery-surfaced candidate (config is partial + non-secret). */
20373
- var DiscoveredTargetSchema = object({
20374
- kind: string(),
20375
- suggestedName: string(),
20376
- config: record(string(), unknown())
20377
- });
20378
- /** The degrade engine's report — what was resolved / dropped / degraded. */
20379
- var RenderedAsSchema = object({
20380
- level: string(),
20381
- format: NotificationFormatSchema,
20382
- attachmentsSent: number().int().nonnegative(),
20383
- actionsSent: number().int().nonnegative(),
20384
- truncated: boolean(),
20385
- dropped: array(string())
20993
+ createdAt: number(),
20994
+ updatedAt: number(),
20995
+ /** Server-stamped author. */
20996
+ createdBy: string(),
20997
+ status: CoreBlockStatusSchema,
20998
+ /**
20999
+ * Why the block is not running, when it is not. The operator's ONLY window
21000
+ * into a block that failed at load — a block that is silently absent is the
21001
+ * failure mode this whole feature has to avoid.
21002
+ */
21003
+ lastError: string().optional(),
21004
+ /** Ms epoch of the last state change. */
21005
+ lastChangedAt: number()
20386
21006
  });
20387
- var SendResultSchema = object({
20388
- success: boolean(),
21007
+ /** What a compile attempt produced. */
21008
+ var CoreBlockCompileResultSchema = object({
21009
+ ok: boolean(),
21010
+ /** Present when `ok` is false — the first error, in the author's words. */
20389
21011
  error: string().optional(),
20390
- renderedAs: RenderedAsSchema.optional()
21012
+ line: number().optional(),
21013
+ column: number().optional()
20391
21014
  });
20392
- /** Same shape as SendResult kept as a distinct name for the test panel. */
20393
- var TestResultSchema = SendResultSchema;
20394
- var notificationOutputCapability = {
20395
- name: "notification-output",
20396
- scope: "system",
20397
- mode: "collection",
20398
- methods: {
20399
- listTargetKinds: method(object({}), array(TargetKindSchema)),
20400
- listTargets: method(object({}), array(TargetSchema)),
20401
- discoverTargets: method(object({
20402
- kind: string(),
20403
- config: record(string(), unknown()).optional()
20404
- }), array(DiscoveredTargetSchema)),
20405
- send: method(object({
20406
- targetId: string(),
20407
- notification: NotificationSchema
20408
- }), SendResultSchema, { kind: "mutation" }),
20409
- testTarget: method(object({
20410
- targetId: string(),
20411
- sample: NotificationSchema.optional()
20412
- }), TestResultSchema, { kind: "mutation" }),
20413
- upsertTarget: method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }),
20414
- deleteTarget: method(object({ targetId: string() }), _void(), { kind: "mutation" }),
20415
- setTargetEnabled: method(object({
20416
- targetId: string(),
20417
- enabled: boolean()
20418
- }), _void(), { kind: "mutation" })
20419
- }
20420
- };
21015
+ 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 }), {
21016
+ kind: "mutation",
21017
+ auth: "admin",
21018
+ caller: "required"
21019
+ }), method(object({
21020
+ blockId: string(),
21021
+ block: CoreBlockInputSchema.partial()
21022
+ }), object({ block: CoreBlockSchema }), {
21023
+ kind: "mutation",
21024
+ auth: "admin",
21025
+ caller: "required"
21026
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
21027
+ kind: "mutation",
21028
+ auth: "admin"
21029
+ }), method(object({
21030
+ blockId: string(),
21031
+ enabled: boolean()
21032
+ }), object({ block: CoreBlockSchema }), {
21033
+ kind: "mutation",
21034
+ auth: "admin"
21035
+ }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
21036
+ kind: "mutation",
21037
+ auth: "admin"
21038
+ }), method(object({}), object({ libs: array(object({
21039
+ filePath: string(),
21040
+ content: string()
21041
+ })) }), { auth: "admin" });
20421
21042
  /**
20422
21043
  * Zod schemas for persisted record types.
20423
21044
  *
@@ -20665,6 +21286,11 @@ var EventKindDescriptorSchema = object({
20665
21286
  deviceId: number()
20666
21287
  })
20667
21288
  });
21289
+ /** One camera's event vocabulary, as returned by `listEventKindsBatch`. */
21290
+ var EventKindsForDeviceSchema = object({
21291
+ deviceId: number(),
21292
+ kinds: array(EventKindDescriptorSchema).readonly()
21293
+ });
20668
21294
  var SensorEventSchema = object({
20669
21295
  id: string(),
20670
21296
  /** The CAMERA the event is attributed to (a sensor linked to N cameras
@@ -20921,6 +21547,19 @@ var MediaFileSchema = object({
20921
21547
  sizeBytes: number(),
20922
21548
  timestamp: number()
20923
21549
  });
21550
+ /**
21551
+ * One media row WITHOUT its bytes.
21552
+ *
21553
+ * A track's media is 5-8 MB of base64 (measured: 7.67 MB across 23 files for a
21554
+ * 140 s track), and a client that renders tiles from the media data plane needs
21555
+ * to know only WHAT EXISTS — the bytes then arrive per tile, lazily, over HTTP
21556
+ * with an immutable cache, instead of all at once inside a tRPC response that
21557
+ * blocks the whole view.
21558
+ *
21559
+ * `sizeBytes` is carried because it is what lets a client decide between the
21560
+ * stored blob and a `?variant=thumb` rendering without fetching either.
21561
+ */
21562
+ var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
20924
21563
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
20925
21564
  var MAX_EVENT_QUERY_LIMIT = 5e3;
20926
21565
  var DeviceEventQueryInput = object({
@@ -21070,7 +21709,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21070
21709
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
21071
21710
  kind: "mutation",
21072
21711
  auth: "admin"
21073
- }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({
21712
+ }), 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({
21074
21713
  deviceId: number(),
21075
21714
  since: number().optional(),
21076
21715
  until: number().optional(),
@@ -21144,7 +21783,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21144
21783
  }), array(MediaFileSchema).readonly()), method(object({
21145
21784
  trackId: string(),
21146
21785
  kinds: array(MediaFileKindEnum).optional()
21147
- }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
21786
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
21148
21787
  deviceId: number(),
21149
21788
  timestamp: number(),
21150
21789
  frameWidth: number(),
@@ -21774,6 +22413,28 @@ var ServerUpdateStateSchema = _enum([
21774
22413
  "pending-restart",
21775
22414
  "awaiting-confirmation"
21776
22415
  ]);
22416
+ var ImageContractSchema = object({
22417
+ state: _enum([
22418
+ "in-sync",
22419
+ "behind-patch",
22420
+ "behind-series",
22421
+ "ahead",
22422
+ "unknown"
22423
+ ]),
22424
+ /** The baked seed closure version — the image/app-bundle fingerprint. */
22425
+ seedVersion: string().nullable(),
22426
+ /** Best-known version the deployment contract delivers today. */
22427
+ contractVersion: string().nullable(),
22428
+ /**
22429
+ * Where `contractVersion` came from: a real registry check (`registry`), or
22430
+ * the node's own running version (`running` — a node can never run code
22431
+ * newer than the newest release, so `seed < running` proves image staleness
22432
+ * even before any registry check has run).
22433
+ */
22434
+ contractSource: _enum(["registry", "running"]).nullable(),
22435
+ /** One operator-grade sentence: this node runs image X; the contract says Y. */
22436
+ message: string()
22437
+ });
21777
22438
  var ServerRollbackInfoSchema = object({
21778
22439
  /** The version that failed (or was manually rolled back). */
21779
22440
  fromVersion: string(),
@@ -21810,7 +22471,12 @@ var ServerPackageStatusSchema = object({
21810
22471
  * versions are being IGNORED. Surfaced as a warning in the UI.
21811
22472
  */
21812
22473
  stateFileCorrupt: boolean(),
21813
- lastCheckedAtMs: number().nullable()
22474
+ lastCheckedAtMs: number().nullable(),
22475
+ /**
22476
+ * Seed-vs-contract verdict (see {@link ImageContractSchema}). Optional for
22477
+ * version skew: an older provider's payload simply omits it.
22478
+ */
22479
+ imageContract: ImageContractSchema.optional()
21814
22480
  });
21815
22481
  var ServerUpdateCheckResultSchema = object({
21816
22482
  packageName: string(),
@@ -21845,101 +22511,6 @@ version: string().optional() }), ServerUpdateActionResultSchema, {
21845
22511
  auth: "admin"
21846
22512
  });
21847
22513
  /**
21848
- * Query filter for settings-store collections.
21849
- */
21850
- var QueryFilterSchema = object({
21851
- where: record(string(), unknown()).optional(),
21852
- whereIn: record(string(), array(unknown())).optional(),
21853
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
21854
- orderBy: object({
21855
- field: string(),
21856
- direction: _enum(["asc", "desc"])
21857
- }).optional(),
21858
- limit: number().optional(),
21859
- offset: number().optional()
21860
- });
21861
- /** A single stored record: `{ id, data }`. */
21862
- var SettingsRecordSchema = object({
21863
- id: string(),
21864
- data: record(string(), unknown())
21865
- });
21866
- /**
21867
- * Column declaration for a structured (SQL-backed) collection.
21868
- *
21869
- * Logical types — the backend translates each to the matching SQLite
21870
- * storage class and handles per-type marshaling:
21871
- * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
21872
- * - `JSON` — TEXT under the hood; serialised on write, parsed on read
21873
- * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
21874
- */
21875
- var CollectionColumnSchema = object({
21876
- name: string(),
21877
- type: _enum([
21878
- "TEXT",
21879
- "INTEGER",
21880
- "REAL",
21881
- "JSON",
21882
- "BOOLEAN"
21883
- ]),
21884
- primaryKey: boolean().optional(),
21885
- notNull: boolean().optional(),
21886
- unique: boolean().optional()
21887
- });
21888
- var CollectionIndexSchema = object({
21889
- name: string(),
21890
- columns: array(string()).readonly(),
21891
- unique: boolean().optional()
21892
- });
21893
- method(object({
21894
- namespace: string().optional(),
21895
- collection: string(),
21896
- key: string()
21897
- }), unknown()), method(object({
21898
- namespace: string().optional(),
21899
- collection: string(),
21900
- key: string(),
21901
- value: unknown()
21902
- }), _void(), { kind: "mutation" }), method(object({
21903
- namespace: string().optional(),
21904
- collection: string(),
21905
- filter: QueryFilterSchema.optional()
21906
- }), array(SettingsRecordSchema).readonly()), method(object({
21907
- namespace: string().optional(),
21908
- collection: string(),
21909
- record: SettingsRecordSchema
21910
- }), _void(), { kind: "mutation" }), method(object({
21911
- namespace: string().optional(),
21912
- collection: string(),
21913
- id: string(),
21914
- data: record(string(), unknown())
21915
- }), _void(), { kind: "mutation" }), method(object({
21916
- namespace: string().optional(),
21917
- collection: string(),
21918
- key: string()
21919
- }), _void(), { kind: "mutation" }), method(object({
21920
- namespace: string().optional(),
21921
- collection: string(),
21922
- filter: QueryFilterSchema.optional()
21923
- }), number()), method(object({
21924
- namespace: string().optional(),
21925
- collection: string(),
21926
- field: string(),
21927
- bucketSize: number().int().positive(),
21928
- origin: number().int(),
21929
- filter: QueryFilterSchema.optional()
21930
- }), array(object({
21931
- bucket: number().int(),
21932
- count: number().int()
21933
- })).readonly()), method(object({
21934
- namespace: string().optional(),
21935
- collection: string()
21936
- }), boolean()), method(object({
21937
- namespace: string().optional(),
21938
- collection: string(),
21939
- columns: array(CollectionColumnSchema).readonly(),
21940
- indexes: array(CollectionIndexSchema).readonly().optional()
21941
- }), _void(), { kind: "mutation" });
21942
- /**
21943
22514
  * `smtp-provider` — pluggable email delivery surface.
21944
22515
  *
21945
22516
  * Collection cap: a deployment may install multiple SMTP relays (e.g.
@@ -25891,6 +26462,54 @@ Object.freeze({
25891
26462
  addonId: null,
25892
26463
  access: "create"
25893
26464
  },
26465
+ "coreBlocks.compile": {
26466
+ capName: "core-blocks",
26467
+ capScope: "system",
26468
+ addonId: null,
26469
+ access: "create"
26470
+ },
26471
+ "coreBlocks.create": {
26472
+ capName: "core-blocks",
26473
+ capScope: "system",
26474
+ addonId: null,
26475
+ access: "create"
26476
+ },
26477
+ "coreBlocks.delete": {
26478
+ capName: "core-blocks",
26479
+ capScope: "system",
26480
+ addonId: null,
26481
+ access: "delete"
26482
+ },
26483
+ "coreBlocks.get": {
26484
+ capName: "core-blocks",
26485
+ capScope: "system",
26486
+ addonId: null,
26487
+ access: "view"
26488
+ },
26489
+ "coreBlocks.getTypeDefs": {
26490
+ capName: "core-blocks",
26491
+ capScope: "system",
26492
+ addonId: null,
26493
+ access: "view"
26494
+ },
26495
+ "coreBlocks.list": {
26496
+ capName: "core-blocks",
26497
+ capScope: "system",
26498
+ addonId: null,
26499
+ access: "view"
26500
+ },
26501
+ "coreBlocks.setEnabled": {
26502
+ capName: "core-blocks",
26503
+ capScope: "system",
26504
+ addonId: null,
26505
+ access: "create"
26506
+ },
26507
+ "coreBlocks.update": {
26508
+ capName: "core-blocks",
26509
+ capScope: "system",
26510
+ addonId: null,
26511
+ access: "create"
26512
+ },
25894
26513
  "cover.close": {
25895
26514
  capName: "cover",
25896
26515
  capScope: "device",
@@ -25927,6 +26546,84 @@ Object.freeze({
25927
26546
  addonId: null,
25928
26547
  access: "view"
25929
26548
  },
26549
+ "dataStoreProvider.count": {
26550
+ capName: "data-store-provider",
26551
+ capScope: "system",
26552
+ addonId: null,
26553
+ access: "view"
26554
+ },
26555
+ "dataStoreProvider.declareCollection": {
26556
+ capName: "data-store-provider",
26557
+ capScope: "system",
26558
+ addonId: null,
26559
+ access: "create"
26560
+ },
26561
+ "dataStoreProvider.delete": {
26562
+ capName: "data-store-provider",
26563
+ capScope: "system",
26564
+ addonId: null,
26565
+ access: "delete"
26566
+ },
26567
+ "dataStoreProvider.deleteWhere": {
26568
+ capName: "data-store-provider",
26569
+ capScope: "system",
26570
+ addonId: null,
26571
+ access: "delete"
26572
+ },
26573
+ "dataStoreProvider.get": {
26574
+ capName: "data-store-provider",
26575
+ capScope: "system",
26576
+ addonId: null,
26577
+ access: "view"
26578
+ },
26579
+ "dataStoreProvider.getEngineInfo": {
26580
+ capName: "data-store-provider",
26581
+ capScope: "system",
26582
+ addonId: null,
26583
+ access: "view"
26584
+ },
26585
+ "dataStoreProvider.histogram": {
26586
+ capName: "data-store-provider",
26587
+ capScope: "system",
26588
+ addonId: null,
26589
+ access: "view"
26590
+ },
26591
+ "dataStoreProvider.insert": {
26592
+ capName: "data-store-provider",
26593
+ capScope: "system",
26594
+ addonId: null,
26595
+ access: "create"
26596
+ },
26597
+ "dataStoreProvider.isEmpty": {
26598
+ capName: "data-store-provider",
26599
+ capScope: "system",
26600
+ addonId: null,
26601
+ access: "view"
26602
+ },
26603
+ "dataStoreProvider.query": {
26604
+ capName: "data-store-provider",
26605
+ capScope: "system",
26606
+ addonId: null,
26607
+ access: "view"
26608
+ },
26609
+ "dataStoreProvider.set": {
26610
+ capName: "data-store-provider",
26611
+ capScope: "system",
26612
+ addonId: null,
26613
+ access: "create"
26614
+ },
26615
+ "dataStoreProvider.update": {
26616
+ capName: "data-store-provider",
26617
+ capScope: "system",
26618
+ addonId: null,
26619
+ access: "create"
26620
+ },
26621
+ "dataStoreProvider.updateWhere": {
26622
+ capName: "data-store-provider",
26623
+ capScope: "system",
26624
+ addonId: null,
26625
+ access: "create"
26626
+ },
25930
26627
  "dayNight.getOptions": {
25931
26628
  capName: "day-night",
25932
26629
  capScope: "device",
@@ -27721,12 +28418,24 @@ Object.freeze({
27721
28418
  addonId: null,
27722
28419
  access: "create"
27723
28420
  },
28421
+ "notificationRules.cancelSnooze": {
28422
+ capName: "notification-rules",
28423
+ capScope: "system",
28424
+ addonId: null,
28425
+ access: "create"
28426
+ },
27724
28427
  "notificationRules.createRule": {
27725
28428
  capName: "notification-rules",
27726
28429
  capScope: "system",
27727
28430
  addonId: null,
27728
28431
  access: "create"
27729
28432
  },
28433
+ "notificationRules.createSnooze": {
28434
+ capName: "notification-rules",
28435
+ capScope: "system",
28436
+ addonId: null,
28437
+ access: "create"
28438
+ },
27730
28439
  "notificationRules.deleteRule": {
27731
28440
  capName: "notification-rules",
27732
28441
  capScope: "system",
@@ -27757,6 +28466,12 @@ Object.freeze({
27757
28466
  addonId: null,
27758
28467
  access: "view"
27759
28468
  },
28469
+ "notificationRules.listSnoozes": {
28470
+ capName: "notification-rules",
28471
+ capScope: "system",
28472
+ addonId: null,
28473
+ access: "view"
28474
+ },
27760
28475
  "notificationRules.setRuleEnabled": {
27761
28476
  capName: "notification-rules",
27762
28477
  capScope: "system",
@@ -27961,6 +28676,12 @@ Object.freeze({
27961
28676
  addonId: null,
27962
28677
  access: "view"
27963
28678
  },
28679
+ "pipelineAnalytics.listEventKindsBatch": {
28680
+ capName: "pipeline-analytics",
28681
+ capScope: "device",
28682
+ addonId: null,
28683
+ access: "view"
28684
+ },
27964
28685
  "pipelineAnalytics.listOpsLog": {
27965
28686
  capName: "pipeline-analytics",
27966
28687
  capScope: "device",
@@ -27973,6 +28694,12 @@ Object.freeze({
27973
28694
  addonId: null,
27974
28695
  access: "view"
27975
28696
  },
28697
+ "pipelineAnalytics.listTrackMedia": {
28698
+ capName: "pipeline-analytics",
28699
+ capScope: "device",
28700
+ addonId: null,
28701
+ access: "view"
28702
+ },
27976
28703
  "pipelineAnalytics.listTracks": {
27977
28704
  capName: "pipeline-analytics",
27978
28705
  capScope: "device",
@@ -29005,6 +29732,12 @@ Object.freeze({
29005
29732
  addonId: null,
29006
29733
  access: "delete"
29007
29734
  },
29735
+ "settingsStore.deleteWhere": {
29736
+ capName: "settings-store",
29737
+ capScope: "system",
29738
+ addonId: null,
29739
+ access: "delete"
29740
+ },
29008
29741
  "settingsStore.get": {
29009
29742
  capName: "settings-store",
29010
29743
  capScope: "system",
@@ -29047,6 +29780,12 @@ Object.freeze({
29047
29780
  addonId: null,
29048
29781
  access: "create"
29049
29782
  },
29783
+ "settingsStore.updateWhere": {
29784
+ capName: "settings-store",
29785
+ capScope: "system",
29786
+ addonId: null,
29787
+ access: "create"
29788
+ },
29050
29789
  "smtpProvider.getStatus": {
29051
29790
  capName: "smtp-provider",
29052
29791
  capScope: "system",
@@ -30431,11 +31170,18 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30431
31170
  async onActivate() {
30432
31171
  await super.onActivate();
30433
31172
  await this.refreshFeatures();
30434
- await this.subscribeBrokerEntity();
30435
- this.deviceLogger.info("HA entity ready", { meta: {
31173
+ if (await this.subscribeBrokerEntity()) {
31174
+ this.deviceLogger.info("HA entity ready", { meta: {
31175
+ entityId: this.entityId,
31176
+ type: this.ctx.deviceMeta.type,
31177
+ ...this.features.length > 0 ? { features: [...this.features] } : {}
31178
+ } });
31179
+ return;
31180
+ }
31181
+ this.deviceLogger.warn("HA entity registered but NOT subscribed — it will receive nothing", { meta: {
30436
31182
  entityId: this.entityId,
30437
- type: this.ctx.deviceMeta.type,
30438
- ...this.features.length > 0 ? { features: [...this.features] } : {}
31183
+ brokerId: this.brokerId,
31184
+ type: this.ctx.deviceMeta.type
30439
31185
  } });
30440
31186
  }
30441
31187
  async removeDevice() {
@@ -30539,6 +31285,8 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30539
31285
  * already-attached handler and `handleStatePush` fires with the real
30540
31286
  * current state (seeding the cap-state slice at construction time).
30541
31287
  */
31288
+ /** True when the entity is genuinely subscribed. The caller uses it to
31289
+ * decide whether "ready" is an honest thing to log. */
30542
31290
  async subscribeBrokerEntity() {
30543
31291
  try {
30544
31292
  const result = await this.ctx.api.broker.subscribe.mutate({
@@ -30546,12 +31294,14 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
30546
31294
  filter: { entityIds: [this.entityId] }
30547
31295
  });
30548
31296
  this.brokerSubscriptionId = result.subscriptionId;
31297
+ return true;
30549
31298
  } catch (err) {
30550
31299
  this.deviceLogger.warn("ha-child failed to register broker subscription", { meta: {
30551
31300
  entityId: this.entityId,
30552
31301
  brokerId: this.brokerId,
30553
31302
  error: errMsg(err)
30554
31303
  } });
31304
+ return false;
30555
31305
  }
30556
31306
  }
30557
31307
  attachEventBusListener() {
@@ -36531,7 +37281,7 @@ function createHaIconRouteProvider() {
36531
37281
  /** The single kind this provider contributes to the shared catalog. */
36532
37282
  var HA_NOTIFICATION_KIND = "homeassistant";
36533
37283
  /** addonId-namespaced settings-store collection holding HA notify targets. */
36534
- var HA_TARGETS_COLLECTION = "ha-notification-targets";
37284
+ var HA_TARGETS_COLLECTION = "provider-homeassistant:ha-notification-targets";
36535
37285
  function haConfigSchema() {
36536
37286
  return { sections: [{
36537
37287
  id: "connection",
@@ -36575,6 +37325,7 @@ function homeassistantTargetKind(addonId) {
36575
37325
  max: 1
36576
37326
  },
36577
37327
  actions: 3,
37328
+ actionIcons: true,
36578
37329
  levels: [
36579
37330
  {
36580
37331
  id: "default",
@@ -36630,6 +37381,21 @@ function applyLevel(data, level) {
36630
37381
  }
36631
37382
  data["push"] = push;
36632
37383
  }
37384
+ var HA_ACTION_ICONS = {
37385
+ acknowledge: "mdi:check",
37386
+ dismiss: "mdi:close",
37387
+ silence: "mdi:bell-off",
37388
+ view: "mdi:eye",
37389
+ play: "mdi:play",
37390
+ open: "mdi:door-open",
37391
+ close: "mdi:door-closed",
37392
+ lock: "mdi:lock",
37393
+ unlock: "mdi:lock-open",
37394
+ arm: "mdi:shield-check",
37395
+ disarm: "mdi:shield-off",
37396
+ light: "mdi:lightbulb",
37397
+ alert: "mdi:alert"
37398
+ };
36633
37399
  /**
36634
37400
  * Build the `notify.<service>` `service_data` from a prepared (degraded)
36635
37401
  * notification. PURE — no WS, unit-testable in isolation.
@@ -36646,7 +37412,8 @@ function buildNotifyServiceData(prepared) {
36646
37412
  if (prepared.actions.length > 0) data["actions"] = prepared.actions.map((a) => ({
36647
37413
  action: a.id,
36648
37414
  title: a.label,
36649
- ...a.url !== void 0 ? { uri: a.url } : {}
37415
+ ...a.url !== void 0 ? { uri: a.url } : {},
37416
+ ...a.icon !== void 0 ? { icon: HA_ACTION_ICONS[a.icon] } : {}
36650
37417
  }));
36651
37418
  if (prepared.clickUrl !== null) {
36652
37419
  data["url"] = prepared.clickUrl;
@@ -36990,6 +37757,19 @@ async function reconcileBroker(brokerId, deps) {
36990
37757
  * whose integration no longer exists should be cleaned up. Brokers with no
36991
37758
  * integrationId were created manually and are never auto-removed.
36992
37759
  */
37760
+ /**
37761
+ * Brokers whose spawning integration is gone.
37762
+ *
37763
+ * **The caller REPORTS these; it does not delete them.** This function used to
37764
+ * drive a delete, and on 2026-08-05 that erased the operator's Home Assistant
37765
+ * broker — host and token — when `integrations.list` returned an empty array
37766
+ * during a hub restart: nothing "survived", so everything was an orphan. A
37767
+ * broker cannot be rebuilt from an integration record, so the loss was
37768
+ * permanent and the doorbell simply stopped existing.
37769
+ *
37770
+ * Kept as a pure query because naming an orphan is useful; acting on one
37771
+ * automatically is not.
37772
+ */
36993
37773
  function computeBrokerCleanup(brokers, survivingIntegrationIds) {
36994
37774
  return brokers.filter((b) => b.integrationId !== void 0 && !survivingIntegrationIds.has(b.integrationId)).map((b) => b.id);
36995
37775
  }
@@ -37577,15 +38357,15 @@ var HaProviderAddon = class HaProviderAddon extends BaseDeviceProvider {
37577
38357
  }
37578
38358
  const linkedBrokers = linkBrokersToIntegrations(this.config.brokers, integrationsWithBrokerId);
37579
38359
  if (linkedBrokers.some((b, i) => b !== this.config.brokers[i])) await this.updateGlobalSettings({ brokers: linkedBrokers });
37580
- const toRemove = computeBrokerCleanup(this.config.brokers, survivingIntegrationIds);
37581
- if (toRemove.length === 0) return;
37582
- for (const id of toRemove) await this.requireRegistry().removeEntry(id);
37583
- const nextBrokers = this.config.brokers.filter((b) => !toRemove.includes(b.id));
37584
- await this.updateGlobalSettings({ brokers: nextBrokers });
38360
+ const orphans = computeBrokerCleanup(this.config.brokers, survivingIntegrationIds);
37585
38361
  this.ctx.logger.info("integration→broker reconcile", { meta: {
37586
- brokerCount: nextBrokers.length,
38362
+ brokerCount: this.config.brokers.length,
37587
38363
  integrationCount: haIntegrations.length,
37588
- removed: toRemove.length
38364
+ orphans: orphans.length
38365
+ } });
38366
+ if (orphans.length > 0) this.ctx.logger.warn("broker entries have no surviving integration — KEPT", { meta: {
38367
+ brokerIds: orphans,
38368
+ integrationCount: haIntegrations.length
37589
38369
  } });
37590
38370
  } catch (err) {
37591
38371
  this.ctx.logger.warn("integration→broker reconcile failed", { meta: { error: errMsg(err) } });