@camstack/addon-post-analysis 1.2.26 → 1.2.27

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.
@@ -6535,6 +6535,109 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6535
6535
  function nodePin(nodeId) {
6536
6536
  return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6537
6537
  }
6538
+ /**
6539
+ * AUTO-GENERATED by scripts/generate-device-scoped-caps.ts — DO NOT EDIT.
6540
+ *
6541
+ * Every `scope: 'device'` capability name, as plain data — so a forked runner
6542
+ * can answer "may a rule actuate this?" without importing the schema barrel
6543
+ * (~144MB RSS per runner, D28).
6544
+ *
6545
+ * Coverage: 80 device-scoped capabilities.
6546
+ */
6547
+ var DEVICE_SCOPED_CAPS = new Set([
6548
+ "accessories",
6549
+ "air-quality-sensor",
6550
+ "alarm-panel",
6551
+ "ambient-light-sensor",
6552
+ "audio-analysis",
6553
+ "audio-metrics",
6554
+ "automation-control",
6555
+ "battery",
6556
+ "binary",
6557
+ "brightness",
6558
+ "button",
6559
+ "camera-credentials",
6560
+ "camera-pipeline-config",
6561
+ "camera-streams",
6562
+ "carbon-monoxide",
6563
+ "climate-control",
6564
+ "color",
6565
+ "connectivity",
6566
+ "consumables",
6567
+ "contact",
6568
+ "control",
6569
+ "cover",
6570
+ "day-night",
6571
+ "detection-pipeline",
6572
+ "device-discovery",
6573
+ "device-ops",
6574
+ "device-status",
6575
+ "doorbell",
6576
+ "enum-sensor",
6577
+ "event-emitter",
6578
+ "events",
6579
+ "fan-control",
6580
+ "feature-probe",
6581
+ "flood",
6582
+ "gas",
6583
+ "humidifier",
6584
+ "humidity-sensor",
6585
+ "image",
6586
+ "image-settings",
6587
+ "intercom",
6588
+ "lawn-mower-control",
6589
+ "lock-control",
6590
+ "media-player",
6591
+ "motion",
6592
+ "motion-detection",
6593
+ "motion-trigger",
6594
+ "motion-zones",
6595
+ "native-object-detection",
6596
+ "notifier",
6597
+ "numeric-sensor",
6598
+ "osd",
6599
+ "pet-feeder",
6600
+ "pipeline-analytics",
6601
+ "power-meter",
6602
+ "presence",
6603
+ "pressure-sensor",
6604
+ "privacy-mask",
6605
+ "ptz",
6606
+ "ptz-autotrack",
6607
+ "reboot",
6608
+ "scene-monitor",
6609
+ "script-runner",
6610
+ "smoke",
6611
+ "snapshot",
6612
+ "stream-catalog",
6613
+ "stream-params",
6614
+ "switch",
6615
+ "tamper",
6616
+ "temperature-sensor",
6617
+ "update",
6618
+ "vacuum-control",
6619
+ "valve",
6620
+ "vibration",
6621
+ "videoclips",
6622
+ "water-heater",
6623
+ "weather",
6624
+ "webrtc-session",
6625
+ "zone-analytics",
6626
+ "zone-rules",
6627
+ "zones"
6628
+ ]);
6629
+ /**
6630
+ * True when `capName` is a device capability.
6631
+ *
6632
+ * This is the ONLY boundary on what a notification rule may actuate. A rule can
6633
+ * be authored by a non-admin and the runner executes with the addon's
6634
+ * privileges, so an unbounded action would be an arbitrary RPC channel with a
6635
+ * privilege escalation attached. Device scope excludes the system caps
6636
+ * (`device-manager.removeDevice` and friends) by construction.
6637
+ */
6638
+ function isDeviceScopedCap(capName) {
6639
+ return DEVICE_SCOPED_CAPS.has(capName);
6640
+ }
6538
6641
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
6539
6642
  DeviceType["Camera"] = "camera";
6540
6643
  DeviceType["Hub"] = "hub";
@@ -6920,6 +7023,10 @@ function systemMethod(input, output, options) {
6920
7023
  systemOnly: true
6921
7024
  };
6922
7025
  }
7026
+ /** Shorthand to define an event schema */
7027
+ function event(data) {
7028
+ return { data };
7029
+ }
6923
7030
  var StaticDirOutputSchema$1 = object({ staticDir: string() });
6924
7031
  var VersionOutputSchema$1 = object({ version: string() });
6925
7032
  method(_void(), StaticDirOutputSchema$1), method(_void(), VersionOutputSchema$1);
@@ -9102,6 +9209,249 @@ var AccessoryKind = {
9102
9209
  };
9103
9210
  AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
9104
9211
  DeviceFeature.BatteryOperated;
9212
+ var DeviceConfig = class DeviceConfig {
9213
+ schema;
9214
+ data;
9215
+ persistFn;
9216
+ constructor(schema, data, persist) {
9217
+ this.schema = schema;
9218
+ this.data = data;
9219
+ this.persistFn = persist;
9220
+ }
9221
+ /**
9222
+ * Build a `DeviceConfig` from a persisted blob, with automatic
9223
+ * recovery from schema-validation failures. Boot must never be
9224
+ * blocked by stale persisted values: if Zod rejects the blob,
9225
+ * we drop every offending top-level field, retry, and persist
9226
+ * the cleaned blob so the bad value is healed in the DB on next
9227
+ * write. The most common trigger is a tightened range constraint
9228
+ * (e.g. `max(100) → max(50)`) on a field that already has an
9229
+ * out-of-range value persisted from the previous schema. Without
9230
+ * this safety net, the device would fail to instantiate and end
9231
+ * up with no caps registered — exactly the failure mode that
9232
+ * stranded device 15 when `motionSensitivity: 90` no longer fit
9233
+ * the new `1..50` schema.
9234
+ *
9235
+ * Recovery rules:
9236
+ * 1. Try `safeParse(initialData)`. If it succeeds, done.
9237
+ * 2. On failure, walk `error.issues`, collect the top-level path
9238
+ * of each issue, and drop those keys from `initialData`.
9239
+ * 3. Re-run `safeParse`. If the cleaned blob now passes (Zod
9240
+ * fills the missing keys with schema defaults / undefined for
9241
+ * `.optional()`), persist it via `persist()` so the bad
9242
+ * values disappear from the DB, and return the device.
9243
+ * 4. If the cleaned blob STILL fails (very rare — would require
9244
+ * a non-recoverable required field), fall back to
9245
+ * `schema.parse({})` so the device still boots with pure
9246
+ * schema defaults. Persist nothing in that path so the next
9247
+ * successful `setAll` still writes a coherent blob.
9248
+ */
9249
+ static fromSchema(schema, persist, initialData = {}, onRecover) {
9250
+ const first = schema.safeParse(initialData);
9251
+ if (first.success) return new DeviceConfig(schema, first.data, persist);
9252
+ const droppedKeys = /* @__PURE__ */ new Set();
9253
+ for (const issue of first.error.issues) {
9254
+ const top = issue.path[0];
9255
+ if (typeof top === "string") droppedKeys.add(top);
9256
+ }
9257
+ const cleaned = { ...initialData };
9258
+ for (const k of droppedKeys) delete cleaned[k];
9259
+ const second = schema.safeParse(cleaned);
9260
+ onRecover?.({
9261
+ droppedKeys: [...droppedKeys],
9262
+ issues: first.error.issues
9263
+ });
9264
+ if (second.success) {
9265
+ persist(second.data).catch(() => {});
9266
+ return new DeviceConfig(schema, second.data, persist);
9267
+ }
9268
+ return new DeviceConfig(schema, schema.parse({}), persist);
9269
+ }
9270
+ get values() {
9271
+ return this.data;
9272
+ }
9273
+ get(key) {
9274
+ return this.data[key];
9275
+ }
9276
+ async set(key, value) {
9277
+ const next = this.schema.parse({
9278
+ ...this.data,
9279
+ [key]: value
9280
+ });
9281
+ this.data = next;
9282
+ await this.persistFn(this.data);
9283
+ }
9284
+ /**
9285
+ * Merge an untyped patch onto the current config and persist. Accepts
9286
+ * `Record<string, unknown>` because the patch typically comes from the
9287
+ * UI form layer (a `ConfigField.key → value` map) where the caller
9288
+ * doesn't hold the Zod schema's static type. Runtime validation is
9289
+ * authoritative: `this.schema.parse` rejects unknown keys or invalid
9290
+ * shapes before touching storage.
9291
+ */
9292
+ async setAll(partial) {
9293
+ const next = this.schema.parse({
9294
+ ...this.data,
9295
+ ...partial
9296
+ });
9297
+ this.data = next;
9298
+ await this.persistFn(this.data);
9299
+ }
9300
+ async deleteKey(key) {
9301
+ const { [key]: _, ...rest } = this.data;
9302
+ const next = this.schema.parse(rest);
9303
+ this.data = next;
9304
+ await this.persistFn(this.data);
9305
+ }
9306
+ entries() {
9307
+ const shape = this.schema.shape;
9308
+ return Object.entries(shape).map(([key, fieldSchema]) => ({
9309
+ key,
9310
+ schema: fieldSchema,
9311
+ value: this.data[key],
9312
+ description: fieldSchema.description
9313
+ }));
9314
+ }
9315
+ };
9316
+ /**
9317
+ * Concrete implementation. Routes every successful write through
9318
+ * `writer(capName, slice)` — the kernel hooks this up to
9319
+ * `device-state.setCapSlice`, the canonical cross-layer write
9320
+ * entrypoint, which handles disk persistence (debounced on the hub)
9321
+ * and mirror updates.
9322
+ *
9323
+ * Schema validation runs in-process before the writer is called —
9324
+ * the round-trip should never carry an invalid slice. `flush()`
9325
+ * awaits any in-flight writer promises so shutdown is lossless.
9326
+ *
9327
+ * `initial` is the persisted blob loaded at boot. Slices for caps
9328
+ * whose schema hasn't been installed yet are kept in-memory verbatim
9329
+ * and validated when the cap registers later.
9330
+ */
9331
+ var DeviceRuntimeState = class DeviceRuntimeState {
9332
+ writer;
9333
+ /** In-flight writer promises tracked so `flush()` can await them. */
9334
+ pendingWrites = /* @__PURE__ */ new Set();
9335
+ /** Per-cap committed slice — after schema validation when known. */
9336
+ slices;
9337
+ /** Per-cap registered schema (set by `installCapSchema`). */
9338
+ schemas = /* @__PURE__ */ new Map();
9339
+ listeners = /* @__PURE__ */ new Set();
9340
+ capListeners = /* @__PURE__ */ new Map();
9341
+ constructor(initial, writer) {
9342
+ this.writer = writer;
9343
+ this.slices = /* @__PURE__ */ new Map();
9344
+ for (const [k, v] of Object.entries(initial)) if (v && typeof v === "object" && !Array.isArray(v)) this.slices.set(k, { ...v });
9345
+ }
9346
+ static fromInitial(initial, writer) {
9347
+ return new DeviceRuntimeState(initial, writer);
9348
+ }
9349
+ installCapSchema(capName, schema) {
9350
+ const existing = this.schemas.get(capName);
9351
+ if (existing) {
9352
+ if (existing !== schema) throw new Error(`[DeviceRuntimeState] capability "${capName}" registered a different runtime-state schema; each cap must declare ONE shape across every provider`);
9353
+ return;
9354
+ }
9355
+ this.schemas.set(capName, schema);
9356
+ const stored = this.slices.get(capName);
9357
+ if (stored) {
9358
+ const result = schema.safeParse(stored);
9359
+ if (result.success) this.slices.set(capName, result.data);
9360
+ else this.slices.delete(capName);
9361
+ }
9362
+ }
9363
+ getCapState(capName) {
9364
+ const slice = this.slices.get(capName);
9365
+ if (!slice) return void 0;
9366
+ return Object.freeze({ ...slice });
9367
+ }
9368
+ getCapField(capName, key) {
9369
+ return this.slices.get(capName)?.[key];
9370
+ }
9371
+ setCapState(capName, value) {
9372
+ this.applyCapWrite(capName, value, false);
9373
+ }
9374
+ patchCapState(capName, partial) {
9375
+ this.applyCapWrite(capName, partial, true);
9376
+ }
9377
+ /**
9378
+ * Internal worker. `merge` controls whether `value` replaces or
9379
+ * shallow-merges into the existing slice. Schema validation runs
9380
+ * on the FINAL composed object regardless.
9381
+ */
9382
+ applyCapWrite(capName, value, merge) {
9383
+ const schema = this.schemas.get(capName);
9384
+ if (!schema) throw new Error(`[DeviceRuntimeState] no schema registered for cap "${capName}" — did the device register it via ctx.registerNativeCap before writing?`);
9385
+ const current = this.slices.get(capName) ?? {};
9386
+ const next = merge ? {
9387
+ ...current,
9388
+ ...value
9389
+ } : { ...value };
9390
+ const parsed = schema.parse(next);
9391
+ if (shallowEqual(current, parsed)) return;
9392
+ this.slices.set(capName, parsed);
9393
+ this.fireListeners([capName]);
9394
+ const writePromise = this.writer(capName, { ...parsed }).catch(() => {});
9395
+ this.pendingWrites.add(writePromise);
9396
+ writePromise.finally(() => {
9397
+ this.pendingWrites.delete(writePromise);
9398
+ });
9399
+ }
9400
+ fireListeners(changed) {
9401
+ const snap = this.snapshot();
9402
+ for (const cb of this.listeners) try {
9403
+ cb(changed, snap);
9404
+ } catch {}
9405
+ for (const capName of changed) {
9406
+ const subs = this.capListeners.get(capName);
9407
+ if (!subs) continue;
9408
+ const slice = this.getCapState(capName);
9409
+ for (const cb of subs) try {
9410
+ cb(slice);
9411
+ } catch {}
9412
+ }
9413
+ }
9414
+ subscribe(cb) {
9415
+ this.listeners.add(cb);
9416
+ return () => {
9417
+ this.listeners.delete(cb);
9418
+ };
9419
+ }
9420
+ subscribeCap(capName, cb) {
9421
+ let subs = this.capListeners.get(capName);
9422
+ if (!subs) {
9423
+ subs = /* @__PURE__ */ new Set();
9424
+ this.capListeners.set(capName, subs);
9425
+ }
9426
+ const adapter = (slice) => {
9427
+ cb(slice);
9428
+ };
9429
+ subs.add(adapter);
9430
+ return () => {
9431
+ const set = this.capListeners.get(capName);
9432
+ if (!set) return;
9433
+ set.delete(adapter);
9434
+ if (set.size === 0) this.capListeners.delete(capName);
9435
+ };
9436
+ }
9437
+ snapshot() {
9438
+ const out = {};
9439
+ for (const [k, v] of this.slices) out[k] = Object.freeze({ ...v });
9440
+ return Object.freeze(out);
9441
+ }
9442
+ async flush() {
9443
+ if (this.pendingWrites.size === 0) return;
9444
+ const inflight = [...this.pendingWrites];
9445
+ await Promise.allSettled(inflight);
9446
+ }
9447
+ };
9448
+ function shallowEqual(a, b) {
9449
+ const ak = Object.keys(a);
9450
+ const bk = Object.keys(b);
9451
+ if (ak.length !== bk.length) return false;
9452
+ for (const k of ak) if (a[k] !== b[k]) return false;
9453
+ return true;
9454
+ }
9105
9455
  new Set(["devices", "classes"]);
9106
9456
  /**
9107
9457
  * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
@@ -9290,6 +9640,65 @@ var NcZoneConditionSchema = object({
9290
9640
  /** Quantifier over `ids` — at least one / every one visited. */
9291
9641
  match: _enum(["any", "all"]).default("any")
9292
9642
  });
9643
+ /**
9644
+ * The P1 condition set — a flat AND of groups; absent group = pass;
9645
+ * membership lists are OR within the list (spec §2.3).
9646
+ */
9647
+ /**
9648
+ * What a rule may actuate.
9649
+ *
9650
+ * **No hand-maintained allowlist** (operator decision, and the right one — a
9651
+ * written list of methods is a third parallel map to keep aligned, and this
9652
+ * repo has paid for those). The boundary instead comes from a property the
9653
+ * capabilities already carry: an action may target only a **device-scoped**
9654
+ * capability method.
9655
+ *
9656
+ * That is not decoration. A rule can be authored by a NON-ADMIN — personal
9657
+ * rules are a supported flow — and the executor runs with the addon's
9658
+ * privileges, so an unbounded action is an arbitrary RPC channel with a
9659
+ * privilege escalation attached. Restricting to device scope excludes the
9660
+ * system caps (`device-manager.removeDevice` and friends) by construction,
9661
+ * costs nothing to maintain, and cannot rot: a cap that stops being
9662
+ * device-scoped stops being actuatable in the same change.
9663
+ *
9664
+ * The executor enforces it; {@link NcRuleActionSchema} carries the intent.
9665
+ */
9666
+ /**
9667
+ * One step of a sequence.
9668
+ *
9669
+ * `wait` is a first-class step rather than a property of the next action: it is
9670
+ * what makes a sequence a SEQUENCE and not a list — "unlock, wait 5s, open"
9671
+ * cannot be expressed otherwise.
9672
+ */
9673
+ var NcRuleActionSchema = discriminatedUnion("kind", [object({
9674
+ kind: literal("wait"),
9675
+ seconds: number().min(0).max(300)
9676
+ }), object({
9677
+ kind: literal("cap"),
9678
+ deviceId: number().int(),
9679
+ /** Capability name, e.g. `alarm-panel`. */
9680
+ cap: string().min(1),
9681
+ /** Method on it. The executor refuses a non-device-scoped cap. */
9682
+ method: string().min(1),
9683
+ /** Method arguments, minus `deviceId` (the executor injects it). */
9684
+ args: record(string(), unknown()).optional()
9685
+ })]);
9686
+ /**
9687
+ * Sequences a rule runs, by hook point.
9688
+ *
9689
+ * ONLY `onTrigger` is here, deliberately. The reference also has activation /
9690
+ * deactivation / reset / post-generation hooks, and they are wanted — but this
9691
+ * repo's expensive failure mode is declaring a surface nothing produces, so a
9692
+ * hook appears here in the same change that produces its edge, never before.
9693
+ */
9694
+ var NcRuleActionsSchema = object({
9695
+ /** Runs when the rule MATCHES. */
9696
+ onTrigger: array(object({
9697
+ name: string().min(1).max(120),
9698
+ enabled: boolean(),
9699
+ minDelaySec: number().int().min(0).max(86400).optional(),
9700
+ actions: array(NcRuleActionSchema).min(1)
9701
+ })).optional() });
9293
9702
  var NcConditionsSchema = object({
9294
9703
  /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
9295
9704
  deviceState: object({
@@ -9590,7 +9999,16 @@ var NcRuleInputSchema = object({
9590
9999
  * read as `false` by {@link canSetGlobal} in the engine. Admins are not bound
9591
10000
  * by this flag — see the scope rules on that function.
9592
10001
  */
9593
- snoozeAllowGlobal: boolean().optional()
10002
+ snoozeAllowGlobal: boolean().optional(),
10003
+ /**
10004
+ * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
10005
+ *
10006
+ * This is what makes the rule set the alarm's trigger set without the alarm
10007
+ * being a special case: arming is
10008
+ * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
10009
+ * shape as every other actuation.
10010
+ */
10011
+ actions: NcRuleActionsSchema.optional()
9594
10012
  });
9595
10013
  /**
9596
10014
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -10388,10 +10806,25 @@ var DeviceStatusSchema = object({
10388
10806
  * apart "just came online" from "still online". */
10389
10807
  lastChangedAt: number()
10390
10808
  });
10391
- object({
10392
- deviceId: number(),
10393
- status: DeviceStatusSchema
10394
- });
10809
+ var deviceStatusCapability = {
10810
+ name: "device-status",
10811
+ scope: "device",
10812
+ deviceNative: true,
10813
+ mode: "singleton",
10814
+ methods: {},
10815
+ events: {
10816
+ /** Emitted when `online` transitions. Mirrors the semantics of
10817
+ * `battery.onStatusChanged`. */
10818
+ onStatusChanged: { data: object({
10819
+ deviceId: number(),
10820
+ status: DeviceStatusSchema
10821
+ }) } },
10822
+ status: {
10823
+ schema: DeviceStatusSchema,
10824
+ kind: "push"
10825
+ },
10826
+ runtimeState: DeviceStatusSchema
10827
+ };
10395
10828
  /**
10396
10829
  * Per-device feature/identity probe slice. Holds the runtime-resolved
10397
10830
  * truth about what a device CAN do — which the kernel uses to:
@@ -10449,11 +10882,35 @@ var FeatureProbeStatusSchema = object({
10449
10882
  */
10450
10883
  lastFetchedAt: number()
10451
10884
  });
10452
- object({
10453
- deviceId: number(),
10454
- status: FeatureProbeStatusSchema
10455
- });
10456
- object({
10885
+ var featureProbeCapability = {
10886
+ name: "feature-probe",
10887
+ scope: "device",
10888
+ deviceNative: true,
10889
+ mode: "singleton",
10890
+ methods: {},
10891
+ events: {
10892
+ /** Fires whenever a fresh probe completes (kernel-driven `reprobe()`
10893
+ * or driver-initiated re-detect after a state change). */
10894
+ onProbeChanged: { data: object({
10895
+ deviceId: number(),
10896
+ status: FeatureProbeStatusSchema
10897
+ }) } },
10898
+ status: {
10899
+ schema: FeatureProbeStatusSchema,
10900
+ kind: "push"
10901
+ },
10902
+ runtimeState: FeatureProbeStatusSchema
10903
+ };
10904
+ /**
10905
+ * Multi-metric air-quality slice. Covers CO₂, total VOCs, particulate
10906
+ * matter at PM2.5 / PM10, and a derived AQI index — all optional so
10907
+ * a single-metric source populates only what it observes. Mirrors
10908
+ * the HA `sensor` device_class set (`co2`, `volatile_organic_compounds`,
10909
+ * `pm25`, `pm10`, `aqi`) collapsed into one cap because a typical
10910
+ * air-quality node reports several of these together; modelling them
10911
+ * as siblings keeps a single timestamp + one slice subscription.
10912
+ */
10913
+ var AirQualitySensorStatusSchema = object({
10457
10914
  /** Carbon dioxide concentration in ppm. */
10458
10915
  co2Ppm: number().min(0).optional(),
10459
10916
  /** Total volatile organic compounds in ppb. */
@@ -10477,7 +10934,19 @@ object({
10477
10934
  * auto-formatting when absent. */
10478
10935
  precision: number().int().min(0).max(10).optional()
10479
10936
  });
10480
- DeviceType.Sensor;
10937
+ var airQualitySensorCapability = {
10938
+ name: "air-quality-sensor",
10939
+ scope: "device",
10940
+ deviceNative: true,
10941
+ mode: "singleton",
10942
+ deviceTypes: [DeviceType.Sensor],
10943
+ methods: {},
10944
+ status: {
10945
+ schema: AirQualitySensorStatusSchema,
10946
+ kind: "push"
10947
+ },
10948
+ runtimeState: AirQualitySensorStatusSchema
10949
+ };
10481
10950
  /**
10482
10951
  * Alarm-panel cap. Models HA `alarm_control_panel.*` on
10483
10952
  * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
@@ -10513,7 +10982,7 @@ var AlarmArmModeSchema = _enum([
10513
10982
  "vacation",
10514
10983
  "custom_bypass"
10515
10984
  ]);
10516
- object({
10985
+ var AlarmPanelStatusSchema = object({
10517
10986
  /** Current lifecycle state. */
10518
10987
  state: AlarmStateSchema,
10519
10988
  /** Subset of arm modes the panel accepts. UI renders one button per
@@ -10525,26 +10994,58 @@ object({
10525
10994
  /** Ms epoch when the slice was last updated. */
10526
10995
  lastChangedAt: number()
10527
10996
  });
10528
- DeviceType.AlarmPanel, method(object({
10529
- deviceId: number().int().nonnegative(),
10530
- mode: AlarmArmModeSchema,
10531
- /** Optional PIN code. Required when `requiresCode === true`.
10532
- * Passed through to the upstream service; never persisted. */
10533
- code: string().min(1).optional()
10534
- }), _void(), {
10535
- kind: "mutation",
10536
- auth: "admin"
10537
- }), method(object({
10538
- deviceId: number().int().nonnegative(),
10539
- code: string().min(1).optional()
10540
- }), _void(), {
10541
- kind: "mutation",
10542
- auth: "admin"
10543
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
10544
- kind: "mutation",
10545
- auth: "admin"
10546
- });
10547
- object({
10997
+ var alarmPanelCapability = {
10998
+ name: "alarm-panel",
10999
+ scope: "device",
11000
+ deviceNative: true,
11001
+ mode: "singleton",
11002
+ deviceTypes: [DeviceType.AlarmPanel],
11003
+ methods: {
11004
+ arm: method(object({
11005
+ deviceId: number().int().nonnegative(),
11006
+ mode: AlarmArmModeSchema,
11007
+ /** Optional PIN code. Required when `requiresCode === true`.
11008
+ * Passed through to the upstream service; never persisted. */
11009
+ code: string().min(1).optional()
11010
+ }), _void(), {
11011
+ kind: "mutation",
11012
+ auth: "admin"
11013
+ }),
11014
+ disarm: method(object({
11015
+ deviceId: number().int().nonnegative(),
11016
+ code: string().min(1).optional()
11017
+ }), _void(), {
11018
+ kind: "mutation",
11019
+ auth: "admin"
11020
+ }),
11021
+ /**
11022
+ * Force the panel into the `triggered` state — used by HA
11023
+ * automations to surface external sensor events through the panel
11024
+ * (e.g. a Reolink camera intrusion event firing the security
11025
+ * system). Provider rejects when the panel hardware doesn't
11026
+ * support a software-initiated trigger.
11027
+ */
11028
+ trigger: method(object({ deviceId: number().int().nonnegative() }), _void(), {
11029
+ kind: "mutation",
11030
+ auth: "admin"
11031
+ })
11032
+ },
11033
+ status: {
11034
+ schema: AlarmPanelStatusSchema,
11035
+ kind: "push"
11036
+ },
11037
+ /**
11038
+ * Runtime-state slice — mirrored by the kernel. UI panel reads the
11039
+ * full slice; renders an arm button per `availableModes` entry and
11040
+ * a PIN field iff `requiresCode === true`.
11041
+ */
11042
+ runtimeState: AlarmPanelStatusSchema
11043
+ };
11044
+ /**
11045
+ * Ambient illuminance reading in lux. Drives Home Assistant `sensor`
11046
+ * entries with `device_class: illuminance`.
11047
+ */
11048
+ var AmbientLightSensorStatusSchema = object({
10548
11049
  /** Current illuminance in lux (lx). */
10549
11050
  lux: number().min(0),
10550
11051
  /** Ms epoch when the slice was last updated. */
@@ -10559,7 +11060,19 @@ object({
10559
11060
  * auto-formatting when absent. */
10560
11061
  precision: number().int().min(0).max(10).optional()
10561
11062
  });
10562
- DeviceType.Sensor;
11063
+ var ambientLightSensorCapability = {
11064
+ name: "ambient-light-sensor",
11065
+ scope: "device",
11066
+ deviceNative: true,
11067
+ mode: "singleton",
11068
+ deviceTypes: [DeviceType.Sensor],
11069
+ methods: {},
11070
+ status: {
11071
+ schema: AmbientLightSensorStatusSchema,
11072
+ kind: "push"
11073
+ },
11074
+ runtimeState: AmbientLightSensorStatusSchema
11075
+ };
10563
11076
  /**
10564
11077
  * Per-class audio metrics aggregated over a sliding window.
10565
11078
  */
@@ -10678,7 +11191,18 @@ var audioMetricsCapability = {
10678
11191
  /** Reactive runtime-state mirror — live `device.state.audioMetrics.value`. */
10679
11192
  runtimeState: AudioMetricsSnapshotSchema
10680
11193
  };
10681
- object({
11194
+ /**
11195
+ * Automation-control cap. Models HA `automation.*` entities on
11196
+ * `DeviceType.Automation`. An automation is a trigger+condition+
11197
+ * action rule that can be enabled / disabled and manually fired
11198
+ * via the `trigger` method.
11199
+ *
11200
+ * `trigger` accepts an optional `skipCondition` flag — when true,
11201
+ * the automation's action block runs WITHOUT evaluating its
11202
+ * condition block. Pair with `DeviceFeature.AutomationSkipCondition`
11203
+ * to gate the UI checkbox for the manual-trigger dialog.
11204
+ */
11205
+ var AutomationControlStatusSchema = object({
10682
11206
  /** Whether the automation is currently enabled. Disabled automations
10683
11207
  * ignore their trigger block — manual `trigger` still works. */
10684
11208
  enabled: boolean(),
@@ -10692,22 +11216,43 @@ object({
10692
11216
  /** Ms epoch when the slice was last updated. */
10693
11217
  lastChangedAt: number()
10694
11218
  });
10695
- DeviceType.Automation, method(object({ deviceId: number().int().nonnegative() }), _void(), {
10696
- kind: "mutation",
10697
- auth: "admin"
10698
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
10699
- kind: "mutation",
10700
- auth: "admin"
10701
- }), method(object({
10702
- deviceId: number().int().nonnegative(),
10703
- /** When true, fires the action block while bypassing the
10704
- * automation's condition evaluation. Gated by
10705
- * `DeviceFeature.AutomationSkipCondition`. */
10706
- skipCondition: boolean().optional()
10707
- }), _void(), {
10708
- kind: "mutation",
10709
- auth: "admin"
10710
- });
11219
+ var automationControlCapability = {
11220
+ name: "automation-control",
11221
+ scope: "device",
11222
+ deviceNative: true,
11223
+ mode: "singleton",
11224
+ deviceTypes: [DeviceType.Automation],
11225
+ methods: {
11226
+ enable: method(object({ deviceId: number().int().nonnegative() }), _void(), {
11227
+ kind: "mutation",
11228
+ auth: "admin"
11229
+ }),
11230
+ disable: method(object({ deviceId: number().int().nonnegative() }), _void(), {
11231
+ kind: "mutation",
11232
+ auth: "admin"
11233
+ }),
11234
+ trigger: method(object({
11235
+ deviceId: number().int().nonnegative(),
11236
+ /** When true, fires the action block while bypassing the
11237
+ * automation's condition evaluation. Gated by
11238
+ * `DeviceFeature.AutomationSkipCondition`. */
11239
+ skipCondition: boolean().optional()
11240
+ }), _void(), {
11241
+ kind: "mutation",
11242
+ auth: "admin"
11243
+ })
11244
+ },
11245
+ status: {
11246
+ schema: AutomationControlStatusSchema,
11247
+ kind: "push"
11248
+ },
11249
+ /**
11250
+ * Runtime-state slice — mirrored by the kernel. UI automation tile
11251
+ * reads `enabled` (toggle) + `isRunning` (spinner) + `lastError`
11252
+ * (badge) directly.
11253
+ */
11254
+ runtimeState: AutomationControlStatusSchema
11255
+ };
10711
11256
  /**
10712
11257
  * Battery status snapshot. Emitted by providers whose device is
10713
11258
  * battery-operated (cameras with `DeviceFeature.BatteryOperated`,
@@ -10746,40 +11291,156 @@ var BatteryStatusSchema = object({
10746
11291
  */
10747
11292
  binary: boolean().optional()
10748
11293
  });
10749
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, method(object({
10750
- deviceId: number(),
10751
- /** Bound on the wait. Sensible range 3000–10000ms. */
10752
- timeoutMs: number().int().min(500).max(3e4).default(8e3)
10753
- }), object({
10754
- awoke: boolean(),
10755
- durationMs: number()
10756
- }), { kind: "mutation" }), object({
10757
- deviceId: number(),
10758
- status: BatteryStatusSchema
10759
- });
10760
- object({
11294
+ var batteryCapability = {
11295
+ name: "battery",
11296
+ scope: "device",
11297
+ deviceNative: true,
11298
+ mode: "singleton",
11299
+ deviceTypes: [
11300
+ DeviceType.Camera,
11301
+ DeviceType.Sensor,
11302
+ DeviceType.Button,
11303
+ DeviceType.Switch
11304
+ ],
11305
+ methods: {
11306
+ /**
11307
+ * Explicitly wake the camera from low-power sleep ahead of a
11308
+ * streaming session start. Consumers that initiate a stream
11309
+ * against a sleeping battery cam (HomeKit Secure Video, Alexa
11310
+ * RTCSession, snapshot wrappers) call this with a short timeout
11311
+ * before establishing the media pipeline — the broker's own
11312
+ * passive wake-on-dial works but adds 5–7 seconds to first-frame,
11313
+ * during which the consumer renders a black screen. Pre-waking
11314
+ * compresses that gap.
11315
+ *
11316
+ * Returns `awoke: true` when the firmware acknowledged the wake
11317
+ * before `timeoutMs`. Returns `awoke: false` when it timed out OR
11318
+ * the cap surface is unavailable (no Baichuan / firmware
11319
+ * channel); the caller should still attempt the stream — the
11320
+ * passive broker wake remains as fallback.
11321
+ */
11322
+ wakeForStream: method(object({
11323
+ deviceId: number(),
11324
+ /** Bound on the wait. Sensible range 3000–10000ms. */
11325
+ timeoutMs: number().int().min(500).max(3e4).default(8e3)
11326
+ }), object({
11327
+ awoke: boolean(),
11328
+ durationMs: number()
11329
+ }), { kind: "mutation" }) },
11330
+ events: {
11331
+ /**
11332
+ * Emitted whenever the cached status changes (firmware push OR
11333
+ * poll observes a delta). The DeviceEventPropagator mirrors this
11334
+ * event on the parent chain — subscribing to a camera's source
11335
+ * receives battery events from child accessories automatically.
11336
+ */
11337
+ onStatusChanged: { data: object({
11338
+ deviceId: number(),
11339
+ status: BatteryStatusSchema
11340
+ }) } },
11341
+ status: {
11342
+ schema: BatteryStatusSchema,
11343
+ kind: "push",
11344
+ empty: {
11345
+ percentage: 0,
11346
+ charging: "none",
11347
+ sleeping: false,
11348
+ lastUpdated: 0
11349
+ }
11350
+ },
11351
+ /**
11352
+ * Runtime-state slice — every provider that registers this cap
11353
+ * stores the same shape under `device.runtimeState[battery]`.
11354
+ * Cross-provider uniformity: a Reolink Argus, a Frigate sensor
11355
+ * proxy, an ONVIF battery cam all read/write the same keys.
11356
+ * Consumers (BatteryBadge, snapshot wrapper sleep gate) read once
11357
+ * via `device.runtimeState.getCapState('battery')` regardless of
11358
+ * the underlying driver.
11359
+ */
11360
+ runtimeState: BatteryStatusSchema
11361
+ };
11362
+ /**
11363
+ * Generic boolean sensor — last-resort fallback when no domain-
11364
+ * specific binary cap fits (Home Assistant `binary_sensor` without a
11365
+ * known `device_class`, or a domain we haven't typed yet). Pure
11366
+ * pass-through: just the bool + timestamp. Push-driven.
11367
+ *
11368
+ * Prefer the typed alternatives (`contact`, `flood`, `smoke`,
11369
+ * `carbon-monoxide`, `gas`, `tamper`, `vibration`, `connectivity`,
11370
+ * `motion`) when the semantics match — export adapters render those
11371
+ * with the right HomeKit / Alexa display category.
11372
+ */
11373
+ var BinaryStatusSchema = object({
10761
11374
  on: boolean(),
10762
11375
  /** Ms epoch of the last transition. 0 if never observed. */
10763
11376
  lastChangedAt: number()
10764
11377
  });
10765
- DeviceType.Sensor;
10766
- object({
11378
+ var binaryCapability = {
11379
+ name: "binary",
11380
+ scope: "device",
11381
+ deviceNative: true,
11382
+ mode: "singleton",
11383
+ deviceTypes: [DeviceType.Sensor],
11384
+ methods: {},
11385
+ status: {
11386
+ schema: BinaryStatusSchema,
11387
+ kind: "push"
11388
+ },
11389
+ runtimeState: BinaryStatusSchema
11390
+ };
11391
+ /**
11392
+ * Dimmable-light brightness control. Co-exists with `switch` on the
11393
+ * same device — the switch toggles on/off, this cap sets the level
11394
+ * applied when the light is on. Drivers map their per-vendor dim
11395
+ * controls to this single-method surface.
11396
+ *
11397
+ * The cap is intentionally minimal: a single `setBrightness({deviceId,
11398
+ * percentage})` mutation plus the auto-injected `getStatus`. Drivers
11399
+ * that expose richer controls (color temperature, scenes, schedules)
11400
+ * should surface those via the device's `getSettingsUISchema()`
11401
+ * instead of bloating this cap.
11402
+ */
11403
+ var BrightnessStatusSchema = object({
10767
11404
  /** Current level as 0..100 inclusive. Firmware-reported. */
10768
11405
  percentage: number().min(0).max(100),
10769
11406
  /** Ms epoch of the last operator-driven change. Useful for UI freshness. */
10770
11407
  lastChangedAt: number()
10771
11408
  });
10772
- DeviceType.Light, method(object({
10773
- deviceId: number().int().nonnegative(),
10774
- percentage: number().min(0).max(100)
10775
- }), _void(), {
10776
- kind: "mutation",
10777
- auth: "admin"
10778
- }), object({
10779
- deviceId: number(),
10780
- percentage: number().min(0).max(100),
10781
- lastChangedAt: number()
10782
- });
11409
+ var brightnessCapability = {
11410
+ name: "brightness",
11411
+ scope: "device",
11412
+ deviceNative: true,
11413
+ mode: "singleton",
11414
+ deviceTypes: [DeviceType.Light],
11415
+ methods: { setBrightness: method(object({
11416
+ deviceId: number().int().nonnegative(),
11417
+ percentage: number().min(0).max(100)
11418
+ }), _void(), {
11419
+ kind: "mutation",
11420
+ auth: "admin"
11421
+ }) },
11422
+ events: {
11423
+ /**
11424
+ * Emitted whenever the brightness changes — operator action OR
11425
+ * firmware push. Subscribers (UI sliders, automation engines) react
11426
+ * without polling.
11427
+ */
11428
+ onBrightnessChanged: { data: object({
11429
+ deviceId: number(),
11430
+ percentage: number().min(0).max(100),
11431
+ lastChangedAt: number()
11432
+ }) } },
11433
+ status: {
11434
+ schema: BrightnessStatusSchema,
11435
+ kind: "command-driven"
11436
+ },
11437
+ /**
11438
+ * Runtime-state slice — the last applied brightness level, mirrored
11439
+ * by the kernel. Read via `device.state.brightness.value` so UI
11440
+ * sliders surface the current level without polling the provider.
11441
+ */
11442
+ runtimeState: BrightnessStatusSchema
11443
+ };
10783
11444
  /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
10784
11445
  var StreamFormatSchema = _enum([
10785
11446
  "webrtc",
@@ -11199,67 +11860,178 @@ var PickedCamStreamSchema = object({
11199
11860
  /** One-line explanation of why this stream won — for logs / debug UI. */
11200
11861
  reason: string()
11201
11862
  });
11202
- DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
11203
- deviceId: number().int().nonnegative(),
11204
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11205
- hostname: string().optional()
11206
- }), array(RtspRestreamEntrySchema).readonly()), method(object({
11207
- deviceId: number().int().nonnegative(),
11208
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11209
- hostname: string().optional()
11210
- }), array(ProfileRtspEntrySchema).readonly()), method(object({
11211
- deviceId: number().int().nonnegative(),
11212
- requirements: PickStreamRequirementsSchema,
11213
- preferences: PickStreamPreferencesSchema.optional()
11214
- }), PickedCamStreamSchema.nullable()), object({
11215
- deviceId: number().int().nonnegative(),
11216
- camStreams: array(CameraStreamSchema).readonly()
11217
- }), object({
11218
- deviceId: number().int().nonnegative(),
11219
- profileSlots: array(ProfileSlotSchema).readonly()
11220
- }), object({
11221
- online: boolean(),
11222
- slotStatuses: object({
11223
- high: ProfileSlotStatusSchema.optional(),
11224
- mid: ProfileSlotStatusSchema.optional(),
11225
- low: ProfileSlotStatusSchema.optional()
11226
- }),
11227
- slotErrors: object({
11228
- high: string().optional(),
11229
- mid: string().optional(),
11230
- low: string().optional()
11231
- }),
11232
- lastChangedAt: number()
11233
- });
11234
- object({
11235
- detected: boolean(),
11236
- /** Ms epoch of the last transition. 0 if never observed. */
11237
- lastChangedAt: number()
11238
- });
11239
- DeviceType.Sensor;
11240
11863
  /**
11241
- * HVAC / climate control cap. Models the full surface of a HA
11242
- * `climate.*` entity (thermostat, A/C, heat pump, dehumidifier)
11243
- * with a single coherent slice — mode + fan + preset + target +
11244
- * dual-setpoint range + humidity + read-only current readings.
11864
+ * Camera streams — device-scoped facade over the system `stream-broker`.
11245
11865
  *
11246
- * Operation modes (`HvacMode`) follow HA's canonical set; not every
11247
- * device supports every mode — the provider rejects unsupported
11248
- * values at runtime. Min/max/step bounds for the setpoint are surfaced
11249
- * via `getOptions` on the provider — they are static per-device hardware
11250
- * properties persisted in the device config blob.
11251
- *
11252
- * `fanMode` / `preset` are free-form strings because device vendors
11253
- * use vastly different vocabularies (`auto`/`low`/`high` vs
11254
- * `quiet`/`normal`/`turbo`; `eco`/`away`/`sleep` vs `home`/`night`).
11255
- * The provider populates `availableFanModes` / `availablePresets`
11256
- * on the slice so the UI renders a closed-list picker.
11866
+ * Mirrors the slice of broker state relevant to a single device:
11867
+ * - `getCameraStreams()`: the pool of physical streams published for
11868
+ * this device. UI uses it to populate the "Camera Stream" dropdown
11869
+ * under each quality section.
11870
+ * - `getBrokerStreams()`: the (up to 3) profile slots `high/mid/low`
11871
+ * with their current assignment + runtime status. UI uses it for
11872
+ * the WebRTC quality picker, recording target selection, etc.
11257
11873
  *
11258
- * Sub-surface gating paired with `DeviceFeature` flags:
11259
- * - `ClimateDualSetpoint` — heat_cool dual-target range support
11260
- * - `ClimateHumidity` — currentHumidity / targetHumidity surfaces
11261
- * - `ClimateFanMode` — fan-mode selector
11262
- * - `ClimatePreset` — preset selector
11874
+ * Registered for every camera device that has at least one published
11875
+ * cam stream. The provider is owned by the stream-broker addon; reads
11876
+ * go against the broker's in-memory registries. Mutations (assign /
11877
+ * unassign / publish / retract) do NOT live here — they stay on the
11878
+ * system `stream-broker` cap so cross-device / addon-driven flows keep
11879
+ * a single namespace.
11880
+ */
11881
+ var cameraStreamsCapability = {
11882
+ name: "camera-streams",
11883
+ scope: "device",
11884
+ mode: "singleton",
11885
+ kind: "wrapper",
11886
+ defaultActive: true,
11887
+ deviceTypes: [DeviceType.Camera],
11888
+ methods: {
11889
+ getCameraStreams: method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()),
11890
+ getBrokerStreams: method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()),
11891
+ /**
11892
+ * Per-device RAW RTSP restream entries — one per published camStream
11893
+ * that has RTSP restream enabled (`native:main`, `rtsp:sub`, …).
11894
+ *
11895
+ * LIVE-VIEW ONLY. This is the surface the device-details stream
11896
+ * picker uses so an operator can hit each physical stream directly.
11897
+ * Programmatic / external consumers (HAP, Alexa, ha-mqtt, recording)
11898
+ * MUST use `getProfileRtspEntries` instead — picking from raw
11899
+ * variants makes two consumers of the same camera land on two
11900
+ * different physical pulls (e.g. Reolink `native:main` vs
11901
+ * `rtsp:main`) and trips the camera's concurrent-session limit.
11902
+ */
11903
+ getRtspEntries: method(object({
11904
+ deviceId: number().int().nonnegative(),
11905
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11906
+ hostname: string().optional()
11907
+ }), array(RtspRestreamEntrySchema).readonly()),
11908
+ /**
11909
+ * Per-device PROFILE RTSP restream entries — one per ASSIGNED
11910
+ * profile slot (high/mid/low). Each entry's `url` is a profile-keyed
11911
+ * broker restream that aliases the profile's assigned source broker,
11912
+ * so HAP / Alexa / recording / WebRTC all converge on the broker's
11913
+ * single on-demand pull for that profile. This is the supported
11914
+ * exporter-facing surface; the raw `getRtspEntries` is live-view
11915
+ * only. Returns `[]` for a device with no assigned profiles
11916
+ * (cold-start before first publish).
11917
+ */
11918
+ getProfileRtspEntries: method(object({
11919
+ deviceId: number().int().nonnegative(),
11920
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11921
+ hostname: string().optional()
11922
+ }), array(ProfileRtspEntrySchema).readonly()),
11923
+ /**
11924
+ * "Best source stream for these decode constraints". Returns the
11925
+ * camStreamId the caller should dial (or null when no stream
11926
+ * matches). See `PickStreamRequirementsSchema` for the filter shape
11927
+ * and `PickStreamPreferencesSchema` for the ranking inputs.
11928
+ *
11929
+ * Returning null instructs the caller to fall back to its existing
11930
+ * path (derived-broker transcode, profile-slot pick, etc.) — the
11931
+ * picker NEVER ranks `derived:*` candidates as a "match" because
11932
+ * its whole job is to avoid the transcode.
11933
+ */
11934
+ pickStream: method(object({
11935
+ deviceId: number().int().nonnegative(),
11936
+ requirements: PickStreamRequirementsSchema,
11937
+ preferences: PickStreamPreferencesSchema.optional()
11938
+ }), PickedCamStreamSchema.nullable())
11939
+ },
11940
+ events: {
11941
+ /** Fires on publishCameraStream / retractCameraStream. */
11942
+ onCamStreamsChanged: event(object({
11943
+ deviceId: number().int().nonnegative(),
11944
+ camStreams: array(CameraStreamSchema).readonly()
11945
+ })),
11946
+ /** Fires on assignProfile / unassignProfile / runtime status change. */
11947
+ onProfileSlotsChanged: event(object({
11948
+ deviceId: number().int().nonnegative(),
11949
+ profileSlots: array(ProfileSlotSchema).readonly()
11950
+ }))
11951
+ },
11952
+ /**
11953
+ * Per-device live stream-broker state. Persistent settings (RTSP
11954
+ * tokens, profile assignments, pre-buffer config, RTSP-enabled toggles,
11955
+ * streamingDebug) stay in the broker's addon store — they survive
11956
+ * restarts. The slice below carries ONLY what's truly runtime:
11957
+ *
11958
+ * - `online` — at least one profile slot is currently `'streaming'`.
11959
+ * Drivers without a firmware liveness signal (RTSP, ONVIF…) can
11960
+ * subscribe and mirror this into `state.deviceStatus.online`.
11961
+ * - `slotStatuses` — current `ProfileSlotStatus` per profile,
11962
+ * mirroring the runtime-mutable subset of `ProfileSlot`.
11963
+ * - `slotErrors` — last error message per profile (only set when the
11964
+ * corresponding slot is in `'error'`).
11965
+ * - `lastChangedAt` — freshness signal for consumers that want to
11966
+ * reason about how stale the slice is.
11967
+ *
11968
+ * Written by the stream-broker manager on every transition that
11969
+ * affects these aggregates. Read via `device.state.cameraStreams.<field>`
11970
+ * (BaseDevice proxy) or, cross-process, via
11971
+ * `device-state.getCapSlice({deviceId, capName: 'camera-streams'})`.
11972
+ * The cap's `onChanged` event fires automatically on each write so
11973
+ * subscribers get push semantics for free.
11974
+ */
11975
+ runtimeState: object({
11976
+ online: boolean(),
11977
+ slotStatuses: object({
11978
+ high: ProfileSlotStatusSchema.optional(),
11979
+ mid: ProfileSlotStatusSchema.optional(),
11980
+ low: ProfileSlotStatusSchema.optional()
11981
+ }),
11982
+ slotErrors: object({
11983
+ high: string().optional(),
11984
+ mid: string().optional(),
11985
+ low: string().optional()
11986
+ }),
11987
+ lastChangedAt: number()
11988
+ })
11989
+ };
11990
+ /**
11991
+ * Carbon-monoxide alarm sensor. Drives Home Assistant `binary_sensor`
11992
+ * entries with `device_class: carbon_monoxide`. Push-driven.
11993
+ */
11994
+ var CarbonMonoxideStatusSchema = object({
11995
+ detected: boolean(),
11996
+ /** Ms epoch of the last transition. 0 if never observed. */
11997
+ lastChangedAt: number()
11998
+ });
11999
+ var carbonMonoxideCapability = {
12000
+ name: "carbon-monoxide",
12001
+ scope: "device",
12002
+ deviceNative: true,
12003
+ mode: "singleton",
12004
+ deviceTypes: [DeviceType.Sensor],
12005
+ methods: {},
12006
+ status: {
12007
+ schema: CarbonMonoxideStatusSchema,
12008
+ kind: "push"
12009
+ },
12010
+ runtimeState: CarbonMonoxideStatusSchema
12011
+ };
12012
+ /**
12013
+ * HVAC / climate control cap. Models the full surface of a HA
12014
+ * `climate.*` entity (thermostat, A/C, heat pump, dehumidifier)
12015
+ * with a single coherent slice — mode + fan + preset + target +
12016
+ * dual-setpoint range + humidity + read-only current readings.
12017
+ *
12018
+ * Operation modes (`HvacMode`) follow HA's canonical set; not every
12019
+ * device supports every mode — the provider rejects unsupported
12020
+ * values at runtime. Min/max/step bounds for the setpoint are surfaced
12021
+ * via `getOptions` on the provider — they are static per-device hardware
12022
+ * properties persisted in the device config blob.
12023
+ *
12024
+ * `fanMode` / `preset` are free-form strings because device vendors
12025
+ * use vastly different vocabularies (`auto`/`low`/`high` vs
12026
+ * `quiet`/`normal`/`turbo`; `eco`/`away`/`sleep` vs `home`/`night`).
12027
+ * The provider populates `availableFanModes` / `availablePresets`
12028
+ * on the slice so the UI renders a closed-list picker.
12029
+ *
12030
+ * Sub-surface gating paired with `DeviceFeature` flags:
12031
+ * - `ClimateDualSetpoint` — heat_cool dual-target range support
12032
+ * - `ClimateHumidity` — currentHumidity / targetHumidity surfaces
12033
+ * - `ClimateFanMode` — fan-mode selector
12034
+ * - `ClimatePreset` — preset selector
11263
12035
  * - `ClimateSwingVertical` — vertical louver swing toggle
11264
12036
  * - `ClimateSwingHorizontal` — horizontal louver swing toggle
11265
12037
  *
@@ -11277,7 +12049,7 @@ var HvacModeSchema = _enum([
11277
12049
  "fan_only",
11278
12050
  "dry"
11279
12051
  ]);
11280
- object({
12052
+ var ClimateControlStatusSchema = object({
11281
12053
  /** Active HVAC mode. */
11282
12054
  mode: HvacModeSchema,
11283
12055
  /** Available HVAC modes the device accepts. Subset of HvacMode. */
@@ -11321,56 +12093,82 @@ object({
11321
12093
  /** Ms epoch when the slice was last updated (push or command). */
11322
12094
  lastFetchedAt: number()
11323
12095
  });
11324
- DeviceType.Thermostat, DeviceType.Climate, method(object({
11325
- deviceId: number().int().nonnegative(),
11326
- mode: HvacModeSchema
11327
- }), _void(), {
11328
- kind: "mutation",
11329
- auth: "admin"
11330
- }), method(object({
11331
- deviceId: number().int().nonnegative(),
11332
- fanMode: string().min(1)
11333
- }), _void(), {
11334
- kind: "mutation",
11335
- auth: "admin"
11336
- }), method(object({
11337
- deviceId: number().int().nonnegative(),
11338
- preset: string().min(1)
11339
- }), _void(), {
11340
- kind: "mutation",
11341
- auth: "admin"
11342
- }), method(object({
11343
- deviceId: number().int().nonnegative(),
11344
- target: number()
11345
- }), _void(), {
11346
- kind: "mutation",
11347
- auth: "admin"
11348
- }), method(object({
11349
- deviceId: number().int().nonnegative(),
11350
- targetLow: number(),
11351
- targetHigh: number()
11352
- }), _void(), {
11353
- kind: "mutation",
11354
- auth: "admin"
11355
- }), method(object({
11356
- deviceId: number().int().nonnegative(),
11357
- targetHumidity: number().min(0).max(100)
11358
- }), _void(), {
11359
- kind: "mutation",
11360
- auth: "admin"
11361
- }), method(object({
11362
- deviceId: number().int().nonnegative(),
11363
- on: boolean()
11364
- }), _void(), {
11365
- kind: "mutation",
11366
- auth: "admin"
11367
- }), method(object({
11368
- deviceId: number().int().nonnegative(),
11369
- on: boolean()
11370
- }), _void(), {
11371
- kind: "mutation",
11372
- auth: "admin"
11373
- });
12096
+ var climateControlCapability = {
12097
+ name: "climate-control",
12098
+ scope: "device",
12099
+ deviceNative: true,
12100
+ mode: "singleton",
12101
+ deviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
12102
+ methods: {
12103
+ setMode: method(object({
12104
+ deviceId: number().int().nonnegative(),
12105
+ mode: HvacModeSchema
12106
+ }), _void(), {
12107
+ kind: "mutation",
12108
+ auth: "admin"
12109
+ }),
12110
+ setFanMode: method(object({
12111
+ deviceId: number().int().nonnegative(),
12112
+ fanMode: string().min(1)
12113
+ }), _void(), {
12114
+ kind: "mutation",
12115
+ auth: "admin"
12116
+ }),
12117
+ setPreset: method(object({
12118
+ deviceId: number().int().nonnegative(),
12119
+ preset: string().min(1)
12120
+ }), _void(), {
12121
+ kind: "mutation",
12122
+ auth: "admin"
12123
+ }),
12124
+ setTarget: method(object({
12125
+ deviceId: number().int().nonnegative(),
12126
+ target: number()
12127
+ }), _void(), {
12128
+ kind: "mutation",
12129
+ auth: "admin"
12130
+ }),
12131
+ setTargetRange: method(object({
12132
+ deviceId: number().int().nonnegative(),
12133
+ targetLow: number(),
12134
+ targetHigh: number()
12135
+ }), _void(), {
12136
+ kind: "mutation",
12137
+ auth: "admin"
12138
+ }),
12139
+ setTargetHumidity: method(object({
12140
+ deviceId: number().int().nonnegative(),
12141
+ targetHumidity: number().min(0).max(100)
12142
+ }), _void(), {
12143
+ kind: "mutation",
12144
+ auth: "admin"
12145
+ }),
12146
+ setSwingVertical: method(object({
12147
+ deviceId: number().int().nonnegative(),
12148
+ on: boolean()
12149
+ }), _void(), {
12150
+ kind: "mutation",
12151
+ auth: "admin"
12152
+ }),
12153
+ setSwingHorizontal: method(object({
12154
+ deviceId: number().int().nonnegative(),
12155
+ on: boolean()
12156
+ }), _void(), {
12157
+ kind: "mutation",
12158
+ auth: "admin"
12159
+ })
12160
+ },
12161
+ status: {
12162
+ schema: ClimateControlStatusSchema,
12163
+ kind: "push"
12164
+ },
12165
+ /**
12166
+ * Runtime-state slice — mirrored by the kernel. UI thermostats read
12167
+ * the full slice via `device.state.climate-control.value` and refresh
12168
+ * on every push without re-querying the provider.
12169
+ */
12170
+ runtimeState: ClimateControlStatusSchema
12171
+ };
11374
12172
  /**
11375
12173
  * Color-light cap. Coexists with `switch` (on/off) and `brightness`
11376
12174
  * (level) on the same device — `switch` toggles the bulb, `brightness`
@@ -11422,7 +12220,7 @@ var ColorInputSchema = discriminatedUnion("mode", [
11422
12220
  mireds: number().int().min(50).max(1e3)
11423
12221
  })
11424
12222
  ]);
11425
- object({
12223
+ var ColorStatusSchema = object({
11426
12224
  /** Active color mode — which of `rgb` / `hsv` / `mireds` reflects
11427
12225
  * the bulb's current state. */
11428
12226
  mode: _enum([
@@ -11439,63 +12237,209 @@ object({
11439
12237
  /** Ms epoch of the last operator-driven change. */
11440
12238
  lastChangedAt: number()
11441
12239
  });
11442
- DeviceType.Light, method(object({
11443
- deviceId: number().int().nonnegative(),
11444
- color: ColorInputSchema
11445
- }), _void(), {
11446
- kind: "mutation",
11447
- auth: "admin"
11448
- }), object({
11449
- deviceId: number(),
11450
- mode: _enum([
11451
- "rgb",
11452
- "hsv",
11453
- "mired"
11454
- ]),
11455
- rgb: RgbTripletSchema.optional(),
11456
- hsv: HsvTripletSchema.optional(),
11457
- mireds: number().int().optional(),
11458
- lastChangedAt: number()
11459
- });
11460
- object({
12240
+ var colorCapability = {
12241
+ name: "color",
12242
+ scope: "device",
12243
+ deviceNative: true,
12244
+ mode: "singleton",
12245
+ deviceTypes: [DeviceType.Light],
12246
+ methods: { setColor: method(object({
12247
+ deviceId: number().int().nonnegative(),
12248
+ color: ColorInputSchema
12249
+ }), _void(), {
12250
+ kind: "mutation",
12251
+ auth: "admin"
12252
+ }) },
12253
+ events: {
12254
+ /**
12255
+ * Emitted whenever the color changes — operator action OR firmware
12256
+ * push. Subscribers (UI color pickers, automation engines) react
12257
+ * without polling.
12258
+ */
12259
+ onColorChanged: { data: object({
12260
+ deviceId: number(),
12261
+ mode: _enum([
12262
+ "rgb",
12263
+ "hsv",
12264
+ "mired"
12265
+ ]),
12266
+ rgb: RgbTripletSchema.optional(),
12267
+ hsv: HsvTripletSchema.optional(),
12268
+ mireds: number().int().optional(),
12269
+ lastChangedAt: number()
12270
+ }) } },
12271
+ status: {
12272
+ schema: ColorStatusSchema,
12273
+ kind: "command-driven"
12274
+ },
12275
+ /**
12276
+ * Runtime-state slice — the last applied color, mirrored by the
12277
+ * kernel. Read via `device.state.color.value` so UI pickers surface
12278
+ * the current chromaticity without polling the provider.
12279
+ */
12280
+ runtimeState: ColorStatusSchema
12281
+ };
12282
+ /**
12283
+ * Upstream-system connectivity sensor — distinct from `device-status`,
12284
+ * which is the kernel-managed online/offline flag for the device's
12285
+ * own transport. This cap surfaces an external entity's view of
12286
+ * whether the device is reachable (typical use: HA's
12287
+ * `binary_sensor` with `device_class: connectivity` for a remote
12288
+ * gateway or bridge). Push-driven.
12289
+ */
12290
+ var ConnectivityStatusSchema = object({
11461
12291
  /** True when the upstream system considers the entity connected. */
11462
12292
  connected: boolean(),
11463
12293
  /** Ms epoch of the last transition. 0 if never observed. */
11464
12294
  lastChangedAt: number()
11465
12295
  });
11466
- DeviceType.Sensor;
12296
+ var connectivityCapability = {
12297
+ name: "connectivity",
12298
+ scope: "device",
12299
+ deviceNative: true,
12300
+ mode: "singleton",
12301
+ deviceTypes: [DeviceType.Sensor],
12302
+ methods: {},
12303
+ status: {
12304
+ schema: ConnectivityStatusSchema,
12305
+ kind: "push"
12306
+ },
12307
+ runtimeState: ConnectivityStatusSchema
12308
+ };
12309
+ /**
12310
+ * Generic device-consumables capability — surfaces a device's
12311
+ * maintenance items (vacuum filters/brushes, replaceable cartridges,
12312
+ * descaling cycles, …) with their remaining life and an optional
12313
+ * "Replaced" reset action. Device-agnostic: any provider that knows its
12314
+ * device tracks consumables can register it; the cap declares no
12315
+ * vocabulary of its own — the provider names each item verbatim.
12316
+ *
12317
+ * Like `childLayout`, the cap is INERT until a provider sets items: no
12318
+ * provider populates it by guessing (no HA inference). The UI renders a
12319
+ * "No consumables reported" placeholder when `items` is empty.
12320
+ */
12321
+ /** A single consumable item. Either a continuous `level` (remaining
12322
+ * life %) or a discrete `status` may be known — both may be null when a
12323
+ * provider only knows the item exists. `level` and `status` are not
12324
+ * mutually exclusive; a provider may report both. */
12325
+ var ConsumableItemSchema = object({
12326
+ /** Stable id, e.g. 'main-brush'. */
12327
+ key: string().min(1),
12328
+ /** Display name. */
12329
+ label: string().min(1),
12330
+ /** Remaining life % when known (0..100). */
12331
+ level: number().min(0).max(100).nullable(),
12332
+ /** Discrete state when known (binary mode). */
12333
+ status: _enum(["ok", "replace"]).nullable(),
12334
+ /** Ms epoch of the last replace, when known. */
12335
+ lastResetAt: number().nullable(),
12336
+ /** Whether `reset()` is meaningful for this item. */
12337
+ resettable: boolean()
12338
+ });
11467
12339
  var ConsumablesStatusSchema = object({
11468
- items: array(object({
11469
- /** Stable id, e.g. 'main-brush'. */
11470
- key: string().min(1),
11471
- /** Display name. */
11472
- label: string().min(1),
11473
- /** Remaining life % when known (0..100). */
11474
- level: number().min(0).max(100).nullable(),
11475
- /** Discrete state when known (binary mode). */
11476
- status: _enum(["ok", "replace"]).nullable(),
11477
- /** Ms epoch of the last replace, when known. */
11478
- lastResetAt: number().nullable(),
11479
- /** Whether `reset()` is meaningful for this item. */
11480
- resettable: boolean()
11481
- })),
12340
+ items: array(ConsumableItemSchema),
11482
12341
  lastChangedAt: number()
11483
12342
  });
11484
- Object.values(DeviceType), method(object({
11485
- deviceId: number().int().nonnegative(),
11486
- key: string().min(1)
11487
- }), _void(), {
11488
- kind: "mutation",
11489
- auth: "admin"
11490
- }), ConsumablesStatusSchema.extend({ lastFetchedAt: number() });
11491
- object({
12343
+ var consumablesCapability = {
12344
+ name: "consumables",
12345
+ scope: "device",
12346
+ deviceNative: true,
12347
+ mode: "singleton",
12348
+ deviceTypes: Object.values(DeviceType),
12349
+ deviceConfig: { ui: {
12350
+ kind: "widget",
12351
+ widgetId: "host/consumables-panel",
12352
+ tab: "consumables",
12353
+ topTab: true,
12354
+ label: "Consumables",
12355
+ order: 5
12356
+ } },
12357
+ methods: {
12358
+ /** Mark a consumable as replaced — resets its remaining life. Only
12359
+ * meaningful when the item's `resettable` is true. */
12360
+ reset: method(object({
12361
+ deviceId: number().int().nonnegative(),
12362
+ key: string().min(1)
12363
+ }), _void(), {
12364
+ kind: "mutation",
12365
+ auth: "admin"
12366
+ }) },
12367
+ status: {
12368
+ schema: ConsumablesStatusSchema,
12369
+ kind: "push",
12370
+ empty: {
12371
+ items: [],
12372
+ lastChangedAt: 0
12373
+ },
12374
+ itemArray: {
12375
+ path: "items",
12376
+ keyField: "key",
12377
+ labelField: "label",
12378
+ itemSchema: ConsumableItemSchema,
12379
+ emptyItem: {
12380
+ key: "",
12381
+ label: "",
12382
+ level: null,
12383
+ status: null,
12384
+ lastResetAt: null,
12385
+ resettable: false
12386
+ }
12387
+ }
12388
+ },
12389
+ runtimeState: ConsumablesStatusSchema.extend({ lastFetchedAt: number() })
12390
+ };
12391
+ /**
12392
+ * Door / window / opening / garage / valve contact sensor. Boolean
12393
+ * "is the entry currently open" with the timestamp of the last
12394
+ * transition. Drives Home Assistant `binary_sensor` entries whose
12395
+ * `device_class` is `door`, `window`, `opening`, `garage`, or
12396
+ * `garage_door` — and any future native integration that needs
12397
+ * the same semantics.
12398
+ *
12399
+ * Push-driven: providers update the slice on transition events from
12400
+ * the upstream source (HA WebSocket `state_changed`, ZWave
12401
+ * `notification` …). Consumers read the slice; no polling.
12402
+ */
12403
+ var ContactStatusSchema = object({
11492
12404
  /** True when the entry is open; false when closed. */
11493
12405
  entryOpen: boolean(),
11494
12406
  /** Ms epoch of the last open↔closed transition. 0 if never observed. */
11495
12407
  lastChangedAt: number()
11496
12408
  });
11497
- DeviceType.Sensor;
11498
- object({
12409
+ var contactCapability = {
12410
+ name: "contact",
12411
+ scope: "device",
12412
+ deviceNative: true,
12413
+ mode: "singleton",
12414
+ deviceTypes: [DeviceType.Sensor],
12415
+ methods: {},
12416
+ status: {
12417
+ schema: ContactStatusSchema,
12418
+ kind: "push"
12419
+ },
12420
+ runtimeState: ContactStatusSchema
12421
+ };
12422
+ /**
12423
+ * Status slice — flat object (the framework's `runtimeState` contract
12424
+ * requires a `ZodObject`, not a discriminated union). The `kind`
12425
+ * field discriminates the value type at the type level via the
12426
+ * `ControlNumericValue` / `ControlStringValue` aliases; consumers
12427
+ * narrow with a kind check.
12428
+ *
12429
+ * The `value` field is typed as `number | string`:
12430
+ * - `kind === 'numeric'` → `value` is `number`
12431
+ * - `kind === 'select' | 'text' | 'datetime'` → `value` is `string`
12432
+ *
12433
+ * The `options` array is populated only when `kind === 'select'`
12434
+ * (empty array for the other kinds). It lives in the slice because
12435
+ * HA `select.*` entities may update their options at runtime.
12436
+ *
12437
+ * The `unit`/`min`/`max`/`step` fields are meaningful for
12438
+ * `kind === 'numeric'` and are absent (undefined) for all other
12439
+ * kinds. They are populated live from HA attributes on each state
12440
+ * push — consistent with `options` and the sensor-unit approach.
12441
+ */
12442
+ var ControlStatusSchema = object({
11499
12443
  kind: _enum([
11500
12444
  "numeric",
11501
12445
  "select",
@@ -11557,14 +12501,31 @@ var ControlSetValueInputSchema = discriminatedUnion("kind", [
11557
12501
  value: string().min(1)
11558
12502
  })
11559
12503
  ]);
11560
- DeviceType.Control, method(object({
11561
- deviceId: number().int().nonnegative(),
11562
- control: ControlSetValueInputSchema
11563
- }), _void(), {
11564
- kind: "mutation",
11565
- auth: "admin"
11566
- });
11567
- object({
12504
+ var controlCapability = {
12505
+ name: "control",
12506
+ scope: "device",
12507
+ deviceNative: true,
12508
+ mode: "singleton",
12509
+ deviceTypes: [DeviceType.Control],
12510
+ methods: { setValue: method(object({
12511
+ deviceId: number().int().nonnegative(),
12512
+ control: ControlSetValueInputSchema
12513
+ }), _void(), {
12514
+ kind: "mutation",
12515
+ auth: "admin"
12516
+ }) },
12517
+ status: {
12518
+ schema: ControlStatusSchema,
12519
+ kind: "push"
12520
+ },
12521
+ /**
12522
+ * Runtime-state slice — mirrored by the kernel. UI widgets (slider /
12523
+ * dropdown / text field / date picker) read the slice's discriminant
12524
+ * and value directly without polling the provider.
12525
+ */
12526
+ runtimeState: ControlStatusSchema
12527
+ };
12528
+ var CoverStatusSchema = object({
11568
12529
  /** Lifecycle state of the cover. */
11569
12530
  state: _enum([
11570
12531
  "open",
@@ -11581,28 +12542,50 @@ object({
11581
12542
  /** Ms epoch when the slice was last updated. */
11582
12543
  lastChangedAt: number()
11583
12544
  });
11584
- DeviceType.Cover, method(object({ deviceId: number().int().nonnegative() }), _void(), {
11585
- kind: "mutation",
11586
- auth: "admin"
11587
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11588
- kind: "mutation",
11589
- auth: "admin"
11590
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11591
- kind: "mutation",
11592
- auth: "admin"
11593
- }), method(object({
11594
- deviceId: number().int().nonnegative(),
11595
- position: number().min(0).max(100)
11596
- }), _void(), {
11597
- kind: "mutation",
11598
- auth: "admin"
11599
- }), method(object({
11600
- deviceId: number().int().nonnegative(),
11601
- tiltPosition: number().min(0).max(100)
11602
- }), _void(), {
11603
- kind: "mutation",
11604
- auth: "admin"
11605
- });
12545
+ var coverCapability = {
12546
+ name: "cover",
12547
+ scope: "device",
12548
+ deviceNative: true,
12549
+ mode: "singleton",
12550
+ deviceTypes: [DeviceType.Cover],
12551
+ methods: {
12552
+ open: method(object({ deviceId: number().int().nonnegative() }), _void(), {
12553
+ kind: "mutation",
12554
+ auth: "admin"
12555
+ }),
12556
+ close: method(object({ deviceId: number().int().nonnegative() }), _void(), {
12557
+ kind: "mutation",
12558
+ auth: "admin"
12559
+ }),
12560
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
12561
+ kind: "mutation",
12562
+ auth: "admin"
12563
+ }),
12564
+ setPosition: method(object({
12565
+ deviceId: number().int().nonnegative(),
12566
+ position: number().min(0).max(100)
12567
+ }), _void(), {
12568
+ kind: "mutation",
12569
+ auth: "admin"
12570
+ }),
12571
+ setTiltPosition: method(object({
12572
+ deviceId: number().int().nonnegative(),
12573
+ tiltPosition: number().min(0).max(100)
12574
+ }), _void(), {
12575
+ kind: "mutation",
12576
+ auth: "admin"
12577
+ })
12578
+ },
12579
+ status: {
12580
+ schema: CoverStatusSchema,
12581
+ kind: "push"
12582
+ },
12583
+ /**
12584
+ * Runtime-state slice — mirrored by the kernel. UI controls watch
12585
+ * the slice for live position changes during a move.
12586
+ */
12587
+ runtimeState: CoverStatusSchema
12588
+ };
11606
12589
  /**
11607
12590
  * Vendor-neutral day/night (IR-cut) control — the per-camera config cap
11608
12591
  * shared by reolink / hikvision / amcrest. Models the common firmware
@@ -11632,7 +12615,12 @@ var NormalizedRangeSchema$1 = object({
11632
12615
  max: number(),
11633
12616
  step: number()
11634
12617
  });
11635
- object({
12618
+ /**
12619
+ * Current day/night state. Optional fields are absent when the camera
12620
+ * does not expose that knob (a photocell-less model reports no
12621
+ * `sensitivity`). `lastFetchedAt` feeds the runtime-state bridge.
12622
+ */
12623
+ var DayNightStatusSchema = object({
11636
12624
  mode: DayNightModeSchema,
11637
12625
  /** IR-cut trigger sensitivity, NORMALIZED 0–100 (higher = switches to night sooner). */
11638
12626
  sensitivity: number().optional(),
@@ -11665,13 +12653,33 @@ var DayNightSettingsPatchSchema = object({
11665
12653
  sensitivity: number().optional(),
11666
12654
  switchDelaySec: number().optional()
11667
12655
  });
11668
- DeviceType.Camera, method(object({ deviceId: number() }), DayNightOptionsSchema), method(object({
11669
- deviceId: number(),
11670
- settings: DayNightSettingsPatchSchema
11671
- }), _void(), {
11672
- kind: "mutation",
11673
- auth: "admin"
11674
- });
12656
+ var dayNightCapability = {
12657
+ name: "day-night",
12658
+ scope: "device",
12659
+ deviceNative: true,
12660
+ mode: "singleton",
12661
+ deviceTypes: [DeviceType.Camera],
12662
+ deviceConfig: { ui: {
12663
+ kind: "derived-form",
12664
+ builderId: "day-night",
12665
+ tab: "image"
12666
+ } },
12667
+ methods: {
12668
+ getOptions: method(object({ deviceId: number() }), DayNightOptionsSchema),
12669
+ setSettings: method(object({
12670
+ deviceId: number(),
12671
+ settings: DayNightSettingsPatchSchema
12672
+ }), _void(), {
12673
+ kind: "mutation",
12674
+ auth: "admin"
12675
+ })
12676
+ },
12677
+ status: {
12678
+ schema: DayNightStatusSchema,
12679
+ kind: "poll"
12680
+ },
12681
+ runtimeState: DayNightStatusSchema
12682
+ };
11675
12683
  /**
11676
12684
  * Identity envelope for a device's upstream-system metadata.
11677
12685
  *
@@ -11715,6 +12723,66 @@ var SourceInfoSchema = object({
11715
12723
  raw: record(string(), unknown()).optional()
11716
12724
  });
11717
12725
  /**
12726
+ * Build the synthetic SourceInfo every existing provider falls back to
12727
+ * when no upstream value has been persisted. Keeps non-migrated providers
12728
+ * (Reolink / Hikvision / ONVIF / Frigate / RTSP) functional without code
12729
+ * changes — `id` reuses the CamStack stableId, `system` reuses the addon
12730
+ * id. Real upstream identity replaces this once a provider migrates and
12731
+ * calls `updateSourceInfo()` with concrete values.
12732
+ */
12733
+ function synthesizeSourceInfo(input) {
12734
+ return {
12735
+ id: input.stableId,
12736
+ system: input.addonId
12737
+ };
12738
+ }
12739
+ /**
12740
+ * Shallow-merge a partial patch over a prior SourceInfo. `undefined`
12741
+ * patch values are ignored (no field clobber); to clear a field
12742
+ * explicitly, providers omit it from the patch and rebuild from scratch
12743
+ * via `updateSourceInfo({})` cycles — uncommon enough that we don't
12744
+ * bother with a `null`-means-delete sentinel.
12745
+ *
12746
+ * The `raw` field is also shallow-merged: passing `raw: { foo: 1 }`
12747
+ * merges over existing `raw` keys instead of replacing the whole bag.
12748
+ */
12749
+ function mergeSourceInfo(prev, patch) {
12750
+ const next = { ...prev };
12751
+ for (const [k, v] of Object.entries(patch)) {
12752
+ if (v === void 0) continue;
12753
+ if (k === "raw" && typeof v === "object" && v !== null) {
12754
+ next.raw = {
12755
+ ...prev.raw,
12756
+ ...v
12757
+ };
12758
+ continue;
12759
+ }
12760
+ next[k] = v;
12761
+ }
12762
+ return next;
12763
+ }
12764
+ /**
12765
+ * Key under which `SourceInfo` lives inside `DeviceMeta.metadata`. The
12766
+ * blob is a free-form `Record<string, unknown>` shared with hardware-
12767
+ * identity fields (`manufacturer`, `model`, `firmware`, …); SourceInfo
12768
+ * lives under a single nested key to keep it composable with those.
12769
+ */
12770
+ var SOURCE_INFO_METADATA_KEY = "sourceInfo";
12771
+ /**
12772
+ * Extract a SourceInfo from a `DeviceMeta.metadata` blob, validating
12773
+ * with Zod. Returns `null` when the blob is missing or the nested
12774
+ * `sourceInfo` key fails validation — callers fall back to the
12775
+ * synthetic default in that case (see `synthesizeSourceInfo`).
12776
+ */
12777
+ function extractSourceInfoFromMetadata(metadata) {
12778
+ if (!metadata) return null;
12779
+ const raw = metadata[SOURCE_INFO_METADATA_KEY];
12780
+ if (!raw) return null;
12781
+ const parsed = SourceInfoSchema.safeParse(raw);
12782
+ if (!parsed.success) return null;
12783
+ return parsed.data;
12784
+ }
12785
+ /**
11718
12786
  * device-discovery — device-scoped capability for parents that host /
11719
12787
  * enumerate child devices (Reolink Hub / NVR, ONVIF gateway, future
11720
12788
  * integrations). The parent advertises a list of discoverable children,
@@ -11801,38 +12869,129 @@ var DeviceDiscoveryStatusSchema = object({
11801
12869
  /** Last error surfaced from the source (rendered as a banner). */
11802
12870
  lastError: string().nullable()
11803
12871
  });
11804
- DeviceType.Hub, DeviceDiscoveryStatusSchema.extend({ lastFetchedAt: number().int().nonnegative() }), method(object({ deviceId: number().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly(), {
11805
- kind: "mutation",
11806
- auth: "admin"
11807
- }), method(object({
11808
- deviceId: number().int().nonnegative(),
11809
- childNativeId: string(),
11810
- /** Optional override for the child's display name. */
11811
- name: string().optional()
11812
- }), object({
11813
- deviceId: number().int().nonnegative(),
11814
- stableId: string()
11815
- }), {
11816
- kind: "mutation",
11817
- auth: "admin"
11818
- }), method(object({
11819
- deviceId: number().int().nonnegative(),
11820
- childDeviceId: number().int().nonnegative()
11821
- }), _void(), {
11822
- kind: "mutation",
11823
- auth: "admin"
11824
- });
11825
- object({
12872
+ var deviceDiscoveryCapability = {
12873
+ name: "device-discovery",
12874
+ scope: "device",
12875
+ deviceNative: true,
12876
+ mode: "singleton",
12877
+ deviceTypes: [DeviceType.Hub],
12878
+ status: {
12879
+ schema: DeviceDiscoveryStatusSchema,
12880
+ kind: "poll"
12881
+ },
12882
+ runtimeState: DeviceDiscoveryStatusSchema.extend({ lastFetchedAt: number().int().nonnegative() }),
12883
+ methods: {
12884
+ /**
12885
+ * Snapshot of the current `discovered` list. Returns the
12886
+ * runtime-state cache — call `refreshDiscovery` first if a
12887
+ * fresh round-trip to the source is required.
12888
+ */
12889
+ listDiscovered: method(object({ deviceId: number().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly()),
12890
+ /**
12891
+ * Force the integration to re-enumerate and update the
12892
+ * runtime-state slice. Returns the freshly-enumerated list (also
12893
+ * available via `listDiscovered` post-call).
12894
+ */
12895
+ refreshDiscovery: method(object({ deviceId: number().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly(), {
12896
+ kind: "mutation",
12897
+ auth: "admin"
12898
+ }),
12899
+ /**
12900
+ * Promote a discovered entry to a real child device. The framework
12901
+ * creates the child via `kernel.devices.create()` with
12902
+ * `parentDeviceId = parent.id` and seeds the child's config from
12903
+ * `childInitialConfig` (driver-defined; usually carries channel +
12904
+ * uid + parent reference). Returns the kernel-assigned numeric id.
12905
+ */
12906
+ adoptDevice: method(object({
12907
+ deviceId: number().int().nonnegative(),
12908
+ childNativeId: string(),
12909
+ /** Optional override for the child's display name. */
12910
+ name: string().optional()
12911
+ }), object({
12912
+ deviceId: number().int().nonnegative(),
12913
+ stableId: string()
12914
+ }), {
12915
+ kind: "mutation",
12916
+ auth: "admin"
12917
+ }),
12918
+ /**
12919
+ * Inverse of `adoptDevice`: removes the child device from the
12920
+ * kernel registry. The discovered entry remains in the
12921
+ * enumeration (status updates resume) so the operator can re-adopt
12922
+ * it later without a fresh refresh.
12923
+ */
12924
+ releaseDevice: method(object({
12925
+ deviceId: number().int().nonnegative(),
12926
+ childDeviceId: number().int().nonnegative()
12927
+ }), _void(), {
12928
+ kind: "mutation",
12929
+ auth: "admin"
12930
+ })
12931
+ }
12932
+ };
12933
+ /**
12934
+ * Doorbell button cap. Two kinds of providers coexist behind this cap
12935
+ * name (same pattern as `snapshot`):
12936
+ *
12937
+ * - **Native** providers: registered per-device by device-driver
12938
+ * addons via `ctx.registerNativeCap` — either on a
12939
+ * `DeviceType.Button` accessory with `role: DeviceRole.Doorbell`,
12940
+ * or directly on the camera (Reolink registers at camera level).
12941
+ * Emits an `onPressed` event every time the firmware pushes a
12942
+ * ring; status tracks the last press and a pressCount since start
12943
+ * (diagnostic).
12944
+ *
12945
+ * - **Wrapper** provider: the `virtual-doorbell` system builtin
12946
+ * (`@camstack/system/builtins/doorbell`). Turns ANY binary-ish
12947
+ * device (contact / switch / event-emitter …) into a doorbell for
12948
+ * a camera. `defaultActive: false` — the operator explicitly binds
12949
+ * it per camera in the device-bindings UI, then picks the source
12950
+ * device + trigger in the per-device settings.
12951
+ *
12952
+ * The DeviceEventPropagator re-emits `onPressed` on the camera parent
12953
+ * — subscribers listening at the camera level receive ring events
12954
+ * with `via[]` populated. No code on the parent needed.
12955
+ */
12956
+ var DoorbellStatusSchema = object({
11826
12957
  /** Ms epoch of the last press. null = never observed since this provider started. */
11827
12958
  lastPressedAt: number().nullable(),
11828
12959
  /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
11829
12960
  pressCountSinceStart: number()
11830
12961
  });
11831
- object({
12962
+ var DoorbellPressEventSchema = object({
11832
12963
  deviceId: number(),
11833
12964
  timestamp: number()
11834
12965
  });
11835
- DeviceType.Button, DeviceType.Camera;
12966
+ var doorbellCapability = {
12967
+ name: "doorbell",
12968
+ scope: "device",
12969
+ deviceNative: true,
12970
+ mode: "singleton",
12971
+ kind: "wrapper",
12972
+ defaultActive: false,
12973
+ deviceTypes: [DeviceType.Button, DeviceType.Camera],
12974
+ exposesDeviceSettings: true,
12975
+ methods: {},
12976
+ events: {
12977
+ /**
12978
+ * Fires once per physical press. Reolink delivers via Baichuan
12979
+ * push (`ReolinkSimpleEvent.type === 'doorbell'`). There is no
12980
+ * release/duration — it's a pulse.
12981
+ */
12982
+ onPressed: { data: DoorbellPressEventSchema } },
12983
+ status: {
12984
+ schema: DoorbellStatusSchema,
12985
+ kind: "push"
12986
+ },
12987
+ /**
12988
+ * Runtime-state slice — last press timestamp + lifetime press count.
12989
+ * Mirrored by the kernel and readable via
12990
+ * `device.state.doorbell.value`. UIs can show "last ring 5m ago"
12991
+ * without subscribing.
12992
+ */
12993
+ runtimeState: DoorbellStatusSchema
12994
+ };
11836
12995
  /**
11837
12996
  * Enum-state sensor — a string value picked from a finite option set.
11838
12997
  * Drives HA `sensor` entries with `state_class: enum` (HVAC action
@@ -11848,7 +13007,7 @@ var EnumSensorDateTimeFormatSchema = _enum([
11848
13007
  "time",
11849
13008
  "datetime"
11850
13009
  ]);
11851
- object({
13010
+ var EnumSensorStatusSchema = object({
11852
13011
  value: string(),
11853
13012
  /**
11854
13013
  * Set for `DateTimeSensor`-role sensors so the UI renders the ISO `value`
@@ -11860,7 +13019,19 @@ object({
11860
13019
  /** Ms epoch when the slice was last updated. */
11861
13020
  lastFetchedAt: number()
11862
13021
  });
11863
- DeviceType.Sensor;
13022
+ var enumSensorCapability = {
13023
+ name: "enum-sensor",
13024
+ scope: "device",
13025
+ deviceNative: true,
13026
+ mode: "singleton",
13027
+ deviceTypes: [DeviceType.Sensor],
13028
+ methods: {},
13029
+ status: {
13030
+ schema: EnumSensorStatusSchema,
13031
+ kind: "push"
13032
+ },
13033
+ runtimeState: EnumSensorStatusSchema
13034
+ };
11864
13035
  /**
11865
13036
  * Generic stateless event emitter. Installed on a `DeviceType.EventEmitter`
11866
13037
  * device. Carries the device's EXACT declared event vocabulary verbatim
@@ -11878,12 +13049,25 @@ var EventFireSchema = object({
11878
13049
  timestamp: number(),
11879
13050
  seq: number()
11880
13051
  });
11881
- object({
13052
+ var EventEmitterStatusSchema = object({
11882
13053
  eventTypes: array(string()),
11883
13054
  lastEvent: EventFireSchema.nullable(),
11884
13055
  eventCountSinceStart: number()
11885
13056
  });
11886
- DeviceType.EventEmitter;
13057
+ var eventEmitterCapability = {
13058
+ name: "event-emitter",
13059
+ scope: "device",
13060
+ deviceNative: true,
13061
+ mode: "singleton",
13062
+ deviceTypes: [DeviceType.EventEmitter],
13063
+ methods: {},
13064
+ events: { onEvent: { data: EventFireSchema } },
13065
+ status: {
13066
+ schema: EventEmitterStatusSchema,
13067
+ kind: "push"
13068
+ },
13069
+ runtimeState: EventEmitterStatusSchema
13070
+ };
11887
13071
  /**
11888
13072
  * Fan-control cap. Models HA `fan.*` entity-specific surfaces:
11889
13073
  * speed percentage, preset modes, ceiling-fan direction, and
@@ -11903,7 +13087,7 @@ DeviceType.EventEmitter;
11903
13087
  * - `FanOscillating` — oscillation toggle
11904
13088
  */
11905
13089
  var FanDirectionSchema = _enum(["forward", "reverse"]);
11906
- object({
13090
+ var FanControlStatusSchema = object({
11907
13091
  /** Active speed as 0..100 inclusive. Null when the device has no
11908
13092
  * speed surface (single-speed fan). */
11909
13093
  percentage: number().min(0).max(100).nullable(),
@@ -11924,45 +13108,118 @@ object({
11924
13108
  /** Ms epoch when the slice was last updated. */
11925
13109
  lastChangedAt: number()
11926
13110
  });
11927
- DeviceType.Fan, method(object({
11928
- deviceId: number().int().nonnegative(),
11929
- percentage: number().min(0).max(100)
11930
- }), _void(), {
11931
- kind: "mutation",
11932
- auth: "admin"
11933
- }), method(object({
11934
- deviceId: number().int().nonnegative(),
11935
- preset: string().min(1)
11936
- }), _void(), {
11937
- kind: "mutation",
11938
- auth: "admin"
11939
- }), method(object({
11940
- deviceId: number().int().nonnegative(),
11941
- direction: FanDirectionSchema
11942
- }), _void(), {
11943
- kind: "mutation",
11944
- auth: "admin"
11945
- }), method(object({
11946
- deviceId: number().int().nonnegative(),
11947
- oscillating: boolean()
11948
- }), _void(), {
11949
- kind: "mutation",
11950
- auth: "admin"
11951
- });
11952
- object({
13111
+ var fanControlCapability = {
13112
+ name: "fan-control",
13113
+ scope: "device",
13114
+ deviceNative: true,
13115
+ mode: "singleton",
13116
+ deviceTypes: [DeviceType.Fan],
13117
+ methods: {
13118
+ setPercentage: method(object({
13119
+ deviceId: number().int().nonnegative(),
13120
+ percentage: number().min(0).max(100)
13121
+ }), _void(), {
13122
+ kind: "mutation",
13123
+ auth: "admin"
13124
+ }),
13125
+ setPreset: method(object({
13126
+ deviceId: number().int().nonnegative(),
13127
+ preset: string().min(1)
13128
+ }), _void(), {
13129
+ kind: "mutation",
13130
+ auth: "admin"
13131
+ }),
13132
+ setDirection: method(object({
13133
+ deviceId: number().int().nonnegative(),
13134
+ direction: FanDirectionSchema
13135
+ }), _void(), {
13136
+ kind: "mutation",
13137
+ auth: "admin"
13138
+ }),
13139
+ setOscillating: method(object({
13140
+ deviceId: number().int().nonnegative(),
13141
+ oscillating: boolean()
13142
+ }), _void(), {
13143
+ kind: "mutation",
13144
+ auth: "admin"
13145
+ })
13146
+ },
13147
+ status: {
13148
+ schema: FanControlStatusSchema,
13149
+ kind: "push"
13150
+ },
13151
+ /**
13152
+ * Runtime-state slice — mirrored by the kernel. UI fan speed
13153
+ * sliders read `percentage` for live updates.
13154
+ */
13155
+ runtimeState: FanControlStatusSchema
13156
+ };
13157
+ /**
13158
+ * Water leak / moisture sensor. Boolean "is liquid currently
13159
+ * detected" with the timestamp of the last transition. Drives Home
13160
+ * Assistant `binary_sensor` entries with `device_class: moisture`,
13161
+ * and any future native flood sensor.
13162
+ *
13163
+ * Push-driven from the upstream source.
13164
+ */
13165
+ var FloodStatusSchema = object({
11953
13166
  /** True when leak is currently detected. */
11954
13167
  flooded: boolean(),
11955
13168
  /** Ms epoch of the last flooded↔dry transition. 0 if never observed. */
11956
13169
  lastChangedAt: number()
11957
13170
  });
11958
- DeviceType.Sensor;
11959
- object({
13171
+ var floodCapability = {
13172
+ name: "flood",
13173
+ scope: "device",
13174
+ deviceNative: true,
13175
+ mode: "singleton",
13176
+ deviceTypes: [DeviceType.Sensor],
13177
+ methods: {},
13178
+ status: {
13179
+ schema: FloodStatusSchema,
13180
+ kind: "push"
13181
+ },
13182
+ runtimeState: FloodStatusSchema
13183
+ };
13184
+ /**
13185
+ * Combustible-gas (LPG / methane / hydrogen) alarm sensor. Drives
13186
+ * Home Assistant `binary_sensor` entries with `device_class: gas`.
13187
+ * Push-driven.
13188
+ */
13189
+ var GasStatusSchema = object({
11960
13190
  detected: boolean(),
11961
13191
  /** Ms epoch of the last transition. 0 if never observed. */
11962
13192
  lastChangedAt: number()
11963
13193
  });
11964
- DeviceType.Sensor;
11965
- object({
13194
+ var gasCapability = {
13195
+ name: "gas",
13196
+ scope: "device",
13197
+ deviceNative: true,
13198
+ mode: "singleton",
13199
+ deviceTypes: [DeviceType.Sensor],
13200
+ methods: {},
13201
+ status: {
13202
+ schema: GasStatusSchema,
13203
+ kind: "push"
13204
+ },
13205
+ runtimeState: GasStatusSchema
13206
+ };
13207
+ /**
13208
+ * Humidifier / dehumidifier cap. Models HA `humidifier.*` entities —
13209
+ * an on/off actuator with an optional target-humidity setpoint and an
13210
+ * optional vendor mode (`auto` / `normal` / `baby` / …).
13211
+ *
13212
+ * A climate-family sibling: the slice carries the on/off state, the
13213
+ * current + target relative humidity (0..100), the active mode and its
13214
+ * available set, plus HA's free-form `action` readout
13215
+ * (`humidifying` / `drying` / `idle` / `off`). `minHumidity` /
13216
+ * `maxHumidity` mirror HA's `min_humidity` / `max_humidity` attributes
13217
+ * (null → the UI falls back to a 0..100 range).
13218
+ *
13219
+ * Providers populate only what the hardware reports — `mode` and the
13220
+ * humidity fields stay null when the device has no such surface.
13221
+ */
13222
+ var HumidifierStatusSchema = object({
11966
13223
  /** Whether the humidifier is currently on. */
11967
13224
  on: boolean(),
11968
13225
  /** Current measured relative humidity (0..100). Null when not reported. */
@@ -11984,26 +13241,54 @@ object({
11984
13241
  /** Ms epoch when the slice was last updated. */
11985
13242
  lastChangedAt: number()
11986
13243
  });
11987
- DeviceType.Humidifier, method(object({
11988
- deviceId: number().int().nonnegative(),
11989
- on: boolean()
11990
- }), _void(), {
11991
- kind: "mutation",
11992
- auth: "admin"
11993
- }), method(object({
11994
- deviceId: number().int().nonnegative(),
11995
- humidity: number().min(0).max(100)
11996
- }), _void(), {
11997
- kind: "mutation",
11998
- auth: "admin"
11999
- }), method(object({
12000
- deviceId: number().int().nonnegative(),
12001
- mode: string().min(1)
12002
- }), _void(), {
12003
- kind: "mutation",
12004
- auth: "admin"
12005
- });
12006
- object({
13244
+ var humidifierCapability = {
13245
+ name: "humidifier",
13246
+ scope: "device",
13247
+ deviceNative: true,
13248
+ mode: "singleton",
13249
+ deviceTypes: [DeviceType.Humidifier],
13250
+ methods: {
13251
+ setOn: method(object({
13252
+ deviceId: number().int().nonnegative(),
13253
+ on: boolean()
13254
+ }), _void(), {
13255
+ kind: "mutation",
13256
+ auth: "admin"
13257
+ }),
13258
+ setTargetHumidity: method(object({
13259
+ deviceId: number().int().nonnegative(),
13260
+ humidity: number().min(0).max(100)
13261
+ }), _void(), {
13262
+ kind: "mutation",
13263
+ auth: "admin"
13264
+ }),
13265
+ setMode: method(object({
13266
+ deviceId: number().int().nonnegative(),
13267
+ mode: string().min(1)
13268
+ }), _void(), {
13269
+ kind: "mutation",
13270
+ auth: "admin"
13271
+ })
13272
+ },
13273
+ status: {
13274
+ schema: HumidifierStatusSchema,
13275
+ kind: "push"
13276
+ },
13277
+ /**
13278
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
13279
+ * slice for live humidity / mode changes.
13280
+ */
13281
+ runtimeState: HumidifierStatusSchema
13282
+ };
13283
+ /**
13284
+ * Single-metric humidity reading. Drives Home Assistant `sensor`
13285
+ * entries with `device_class: humidity`.
13286
+ *
13287
+ * Unit normalisation: percent. The canonical display unit (`%`) is a
13288
+ * descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
13289
+ * `sourceInfo`.
13290
+ */
13291
+ var HumiditySensorStatusSchema = object({
12007
13292
  /** Current relative humidity, 0..100. */
12008
13293
  percent: number().min(0).max(100),
12009
13294
  /** Ms epoch when the slice was last updated. */
@@ -12018,15 +13303,57 @@ object({
12018
13303
  * auto-formatting when absent. */
12019
13304
  precision: number().int().min(0).max(10).optional()
12020
13305
  });
12021
- DeviceType.Sensor, DeviceType.Thermostat;
12022
- object({
13306
+ var humiditySensorCapability = {
13307
+ name: "humidity-sensor",
13308
+ scope: "device",
13309
+ deviceNative: true,
13310
+ mode: "singleton",
13311
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat],
13312
+ methods: {},
13313
+ status: {
13314
+ schema: HumiditySensorStatusSchema,
13315
+ kind: "push"
13316
+ },
13317
+ runtimeState: HumiditySensorStatusSchema
13318
+ };
13319
+ /**
13320
+ * Image display cap. Models a single still image exposed by an integration —
13321
+ * a snapshot, a chart, a generated picture, or a robot's cleaning-map render.
13322
+ *
13323
+ * Read-only: there are no setters. The provider resolves whatever upstream
13324
+ * source it has into an ABSOLUTE URL the browser loads directly:
13325
+ * - HA `image.*` entities → the `entity_picture` signed-token path
13326
+ * (token stays in the query string, so no auth header is needed);
13327
+ * - a Dreame/robot map → the cloud/OSS map-image URL (or an addon
13328
+ * data-plane URL serving the rendered map bytes), exposed as its own
13329
+ * Image child device grouped under the robot's container.
13330
+ * The slice carries that URL plus the upstream last-updated timestamp; the
13331
+ * image changes when the source's last-updated marker changes.
13332
+ */
13333
+ var ImageStatusSchema = object({
12023
13334
  /** Absolute signed URL the browser loads directly. Null when the
12024
13335
  * entity exposes no `entity_picture` (yet). */
12025
13336
  url: string().nullable(),
12026
13337
  /** Ms epoch of the upstream last-updated timestamp. Null at cold-start. */
12027
13338
  lastUpdated: number().nullable()
12028
13339
  });
12029
- DeviceType.Image;
13340
+ var imageCapability = {
13341
+ name: "image",
13342
+ scope: "device",
13343
+ deviceNative: true,
13344
+ mode: "singleton",
13345
+ deviceTypes: [DeviceType.Image],
13346
+ methods: {},
13347
+ status: {
13348
+ schema: ImageStatusSchema,
13349
+ kind: "push"
13350
+ },
13351
+ /**
13352
+ * Runtime-state slice — mirrored by the kernel. The UI reads `url`
13353
+ * directly and renders the still image.
13354
+ */
13355
+ runtimeState: ImageStatusSchema
13356
+ };
12030
13357
  /**
12031
13358
  * Vendor-neutral image / picture-adjustment cap — the per-camera config
12032
13359
  * cap shared by reolink / hikvision / amcrest. Models the common ISP
@@ -12078,7 +13405,12 @@ var NormalizedRangeSchema = object({
12078
13405
  max: number(),
12079
13406
  step: number()
12080
13407
  });
12081
- object({
13408
+ /**
13409
+ * Current image-adjustment state. Every field optional — absent when the
13410
+ * camera does not expose that control. Slider values are NORMALIZED 0–100.
13411
+ * `lastFetchedAt` feeds the runtime-state bridge.
13412
+ */
13413
+ var ImageSettingsStatusSchema = object({
12082
13414
  /** Normalized 0–100. */
12083
13415
  brightness: number().optional(),
12084
13416
  /** Normalized 0–100. */
@@ -12144,13 +13476,33 @@ var ImageSettingsPatchSchema = object({
12144
13476
  exposureMode: ExposureModeSchema.optional(),
12145
13477
  backlightMode: BacklightModeSchema.optional()
12146
13478
  });
12147
- DeviceType.Camera, method(object({ deviceId: number() }), ImageSettingsOptionsSchema), method(object({
12148
- deviceId: number(),
12149
- settings: ImageSettingsPatchSchema
12150
- }), _void(), {
12151
- kind: "mutation",
12152
- auth: "admin"
12153
- });
13479
+ var imageSettingsCapability = {
13480
+ name: "image-settings",
13481
+ scope: "device",
13482
+ deviceNative: true,
13483
+ mode: "singleton",
13484
+ deviceTypes: [DeviceType.Camera],
13485
+ deviceConfig: { ui: {
13486
+ kind: "derived-form",
13487
+ builderId: "image-settings",
13488
+ tab: "image"
13489
+ } },
13490
+ methods: {
13491
+ getOptions: method(object({ deviceId: number() }), ImageSettingsOptionsSchema),
13492
+ setSettings: method(object({
13493
+ deviceId: number(),
13494
+ settings: ImageSettingsPatchSchema
13495
+ }), _void(), {
13496
+ kind: "mutation",
13497
+ auth: "admin"
13498
+ })
13499
+ },
13500
+ status: {
13501
+ schema: ImageSettingsStatusSchema,
13502
+ kind: "poll"
13503
+ },
13504
+ runtimeState: ImageSettingsStatusSchema
13505
+ };
12154
13506
  /**
12155
13507
  * Robotic lawn-mower cap. Models HA `lawn_mower.*` entities — anything
12156
13508
  * with a mowing lifecycle plus a dock action.
@@ -12175,7 +13527,7 @@ var DeviceCodeSeveritySchema = _enum([
12175
13527
  "warning",
12176
13528
  "error"
12177
13529
  ]);
12178
- object({
13530
+ var LawnMowerControlStatusSchema = object({
12179
13531
  /** Lifecycle activity of the mower. */
12180
13532
  activity: LawnMowerActivitySchema,
12181
13533
  /** 0..100 battery percentage. Null when the device has no battery
@@ -12195,17 +13547,37 @@ object({
12195
13547
  /** Ms epoch when the slice was last updated. */
12196
13548
  lastChangedAt: number()
12197
13549
  });
12198
- DeviceType.LawnMower, method(object({ deviceId: number().int().nonnegative() }), _void(), {
12199
- kind: "mutation",
12200
- auth: "admin"
12201
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12202
- kind: "mutation",
12203
- auth: "admin"
12204
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12205
- kind: "mutation",
12206
- auth: "admin"
12207
- });
12208
- object({
13550
+ var lawnMowerControlCapability = {
13551
+ name: "lawn-mower-control",
13552
+ scope: "device",
13553
+ deviceNative: true,
13554
+ mode: "singleton",
13555
+ deviceTypes: [DeviceType.LawnMower],
13556
+ methods: {
13557
+ startMowing: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13558
+ kind: "mutation",
13559
+ auth: "admin"
13560
+ }),
13561
+ pause: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13562
+ kind: "mutation",
13563
+ auth: "admin"
13564
+ }),
13565
+ dock: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13566
+ kind: "mutation",
13567
+ auth: "admin"
13568
+ })
13569
+ },
13570
+ status: {
13571
+ schema: LawnMowerControlStatusSchema,
13572
+ kind: "push"
13573
+ },
13574
+ /**
13575
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
13576
+ * slice for live activity + battery changes.
13577
+ */
13578
+ runtimeState: LawnMowerControlStatusSchema
13579
+ };
13580
+ var LockControlStatusSchema = object({
12209
13581
  /** Lifecycle state of the lock. `jammed` means the motor reported
12210
13582
  * failure to reach the target — operator intervention required. */
12211
13583
  state: _enum([
@@ -12218,24 +13590,45 @@ object({
12218
13590
  /** Ms epoch when the slice was last updated. */
12219
13591
  lastChangedAt: number()
12220
13592
  });
12221
- DeviceType.Lock, method(object({
12222
- deviceId: number().int().nonnegative(),
12223
- /** Optional PIN code required by some keypad locks. NOT
12224
- * persisted — passed directly to the upstream service. */
12225
- code: string().min(1).optional()
12226
- }), _void(), {
12227
- kind: "mutation",
12228
- auth: "admin"
12229
- }), method(object({
12230
- deviceId: number().int().nonnegative(),
12231
- code: string().min(1).optional()
12232
- }), _void(), {
12233
- kind: "mutation",
12234
- auth: "admin"
12235
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12236
- kind: "mutation",
12237
- auth: "admin"
12238
- });
13593
+ var lockControlCapability = {
13594
+ name: "lock-control",
13595
+ scope: "device",
13596
+ deviceNative: true,
13597
+ mode: "singleton",
13598
+ deviceTypes: [DeviceType.Lock],
13599
+ methods: {
13600
+ lock: method(object({
13601
+ deviceId: number().int().nonnegative(),
13602
+ /** Optional PIN code required by some keypad locks. NOT
13603
+ * persisted — passed directly to the upstream service. */
13604
+ code: string().min(1).optional()
13605
+ }), _void(), {
13606
+ kind: "mutation",
13607
+ auth: "admin"
13608
+ }),
13609
+ unlock: method(object({
13610
+ deviceId: number().int().nonnegative(),
13611
+ code: string().min(1).optional()
13612
+ }), _void(), {
13613
+ kind: "mutation",
13614
+ auth: "admin"
13615
+ }),
13616
+ open: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13617
+ kind: "mutation",
13618
+ auth: "admin"
13619
+ })
13620
+ },
13621
+ status: {
13622
+ schema: LockControlStatusSchema,
13623
+ kind: "push"
13624
+ },
13625
+ /**
13626
+ * Runtime-state slice — mirrored by the kernel. UI lock buttons
13627
+ * read `state` and disable themselves during `locking`/`unlocking`
13628
+ * transitions.
13629
+ */
13630
+ runtimeState: LockControlStatusSchema
13631
+ };
12239
13632
  /**
12240
13633
  * Media-player cap. Models HA `media_player.*` (Sonos, Chromecast,
12241
13634
  * Apple TV, Spotify, Roku, generic OTT receivers, …) on
@@ -12275,7 +13668,7 @@ var MediaInfoSchema = object({
12275
13668
  /** Optional cover-art / thumbnail URL. */
12276
13669
  imageUrl: string().optional()
12277
13670
  });
12278
- object({
13671
+ var MediaPlayerStatusSchema = object({
12279
13672
  /** Playback lifecycle state. */
12280
13673
  state: MediaPlayerStateSchema,
12281
13674
  /** Volume as 0..100 inclusive. Null when the device has no volume
@@ -12306,68 +13699,98 @@ object({
12306
13699
  /** Ms epoch when the slice was last updated. */
12307
13700
  lastChangedAt: number()
12308
13701
  });
12309
- DeviceType.MediaPlayer, method(object({ deviceId: number().int().nonnegative() }), _void(), {
12310
- kind: "mutation",
12311
- auth: "admin"
12312
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12313
- kind: "mutation",
12314
- auth: "admin"
12315
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12316
- kind: "mutation",
12317
- auth: "admin"
12318
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12319
- kind: "mutation",
12320
- auth: "admin"
12321
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12322
- kind: "mutation",
12323
- auth: "admin"
12324
- }), method(object({
12325
- deviceId: number().int().nonnegative(),
12326
- positionMs: number().int().nonnegative()
12327
- }), _void(), {
12328
- kind: "mutation",
12329
- auth: "admin"
12330
- }), method(object({
12331
- deviceId: number().int().nonnegative(),
12332
- volumeLevel: number().min(0).max(100)
12333
- }), _void(), {
12334
- kind: "mutation",
12335
- auth: "admin"
12336
- }), method(object({
12337
- deviceId: number().int().nonnegative(),
12338
- muted: boolean()
12339
- }), _void(), {
12340
- kind: "mutation",
12341
- auth: "admin"
12342
- }), method(object({
12343
- deviceId: number().int().nonnegative(),
12344
- shuffle: boolean()
12345
- }), _void(), {
12346
- kind: "mutation",
12347
- auth: "admin"
12348
- }), method(object({
12349
- deviceId: number().int().nonnegative(),
12350
- repeat: MediaPlayerRepeatSchema
12351
- }), _void(), {
12352
- kind: "mutation",
12353
- auth: "admin"
12354
- }), method(object({
12355
- deviceId: number().int().nonnegative(),
12356
- source: string().min(1)
12357
- }), _void(), {
12358
- kind: "mutation",
12359
- auth: "admin"
12360
- }), method(object({
12361
- deviceId: number().int().nonnegative(),
12362
- /** Media identifier / URL. */
12363
- mediaId: string().min(1),
12364
- /** Media kind (`music`, `tvshow`, `movie`, `app`, …) — provider
12365
- * passes it through to the upstream service. */
12366
- mediaType: string().min(1)
12367
- }), _void(), {
12368
- kind: "mutation",
12369
- auth: "admin"
12370
- });
13702
+ var mediaPlayerCapability = {
13703
+ name: "media-player",
13704
+ scope: "device",
13705
+ deviceNative: true,
13706
+ mode: "singleton",
13707
+ deviceTypes: [DeviceType.MediaPlayer],
13708
+ methods: {
13709
+ play: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13710
+ kind: "mutation",
13711
+ auth: "admin"
13712
+ }),
13713
+ pause: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13714
+ kind: "mutation",
13715
+ auth: "admin"
13716
+ }),
13717
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13718
+ kind: "mutation",
13719
+ auth: "admin"
13720
+ }),
13721
+ next: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13722
+ kind: "mutation",
13723
+ auth: "admin"
13724
+ }),
13725
+ previous: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13726
+ kind: "mutation",
13727
+ auth: "admin"
13728
+ }),
13729
+ seek: method(object({
13730
+ deviceId: number().int().nonnegative(),
13731
+ positionMs: number().int().nonnegative()
13732
+ }), _void(), {
13733
+ kind: "mutation",
13734
+ auth: "admin"
13735
+ }),
13736
+ setVolume: method(object({
13737
+ deviceId: number().int().nonnegative(),
13738
+ volumeLevel: number().min(0).max(100)
13739
+ }), _void(), {
13740
+ kind: "mutation",
13741
+ auth: "admin"
13742
+ }),
13743
+ setMute: method(object({
13744
+ deviceId: number().int().nonnegative(),
13745
+ muted: boolean()
13746
+ }), _void(), {
13747
+ kind: "mutation",
13748
+ auth: "admin"
13749
+ }),
13750
+ setShuffle: method(object({
13751
+ deviceId: number().int().nonnegative(),
13752
+ shuffle: boolean()
13753
+ }), _void(), {
13754
+ kind: "mutation",
13755
+ auth: "admin"
13756
+ }),
13757
+ setRepeat: method(object({
13758
+ deviceId: number().int().nonnegative(),
13759
+ repeat: MediaPlayerRepeatSchema
13760
+ }), _void(), {
13761
+ kind: "mutation",
13762
+ auth: "admin"
13763
+ }),
13764
+ selectSource: method(object({
13765
+ deviceId: number().int().nonnegative(),
13766
+ source: string().min(1)
13767
+ }), _void(), {
13768
+ kind: "mutation",
13769
+ auth: "admin"
13770
+ }),
13771
+ playMedia: method(object({
13772
+ deviceId: number().int().nonnegative(),
13773
+ /** Media identifier / URL. */
13774
+ mediaId: string().min(1),
13775
+ /** Media kind (`music`, `tvshow`, `movie`, `app`, …) — provider
13776
+ * passes it through to the upstream service. */
13777
+ mediaType: string().min(1)
13778
+ }), _void(), {
13779
+ kind: "mutation",
13780
+ auth: "admin"
13781
+ })
13782
+ },
13783
+ status: {
13784
+ schema: MediaPlayerStatusSchema,
13785
+ kind: "push"
13786
+ },
13787
+ /**
13788
+ * Runtime-state slice — mirrored by the kernel. UI players read the
13789
+ * full slice for live now-playing, volume, and progress updates
13790
+ * without polling.
13791
+ */
13792
+ runtimeState: MediaPlayerStatusSchema
13793
+ };
12371
13794
  /** Shared Zod schemas used across detection capabilities. */
12372
13795
  /**
12373
13796
  * Canonical frame-format enum mirrored on `FrameFormat` in
@@ -12927,25 +14350,58 @@ var ZoneSchema = object({
12927
14350
  /** Visual color for UI rendering. */
12928
14351
  color: string().default("#3b82f6")
12929
14352
  });
12930
- DeviceType.Camera, method(object({ deviceId: number() }), array(ZoneSchema).readonly()), method(object({
12931
- deviceId: number(),
12932
- zone: ZoneSchema
12933
- }), _void(), {
12934
- kind: "mutation",
12935
- auth: "admin"
12936
- }), method(object({
12937
- deviceId: number(),
12938
- zoneId: string()
12939
- }), _void(), {
12940
- kind: "mutation",
12941
- auth: "admin"
12942
- }), method(object({
12943
- deviceId: number(),
12944
- zone: ZoneSchema
12945
- }), _void(), {
12946
- kind: "mutation",
12947
- auth: "admin"
12948
- }), object({ zones: array(ZoneSchema).readonly() });
14353
+ /**
14354
+ * Zones capability — per-camera CRUD over polygon detection zones.
14355
+ *
14356
+ * Provider lives in `addon-pipeline-orchestrator` (hub-only). Persists
14357
+ * to per-device settings and mirrors into the `zones` device-state
14358
+ * slice on every mutation, so downstream consumers can subscribe via
14359
+ * `dev.state.zones.onChanged`.
14360
+ *
14361
+ * The cap surface only handles geometry + identity; filtering
14362
+ * behaviour (per-class, include/exclude, threshold) lives in the
14363
+ * consumer addons' rule arrays — see `ZoneRuleSchema` exported from
14364
+ * `capabilities/schemas/zone-rule.js`.
14365
+ */
14366
+ var zonesCapability = {
14367
+ name: "zones",
14368
+ scope: "device",
14369
+ mode: "singleton",
14370
+ deviceTypes: [DeviceType.Camera],
14371
+ methods: {
14372
+ listZones: method(object({ deviceId: number() }), array(ZoneSchema).readonly()),
14373
+ addZone: method(object({
14374
+ deviceId: number(),
14375
+ zone: ZoneSchema
14376
+ }), _void(), {
14377
+ kind: "mutation",
14378
+ auth: "admin"
14379
+ }),
14380
+ removeZone: method(object({
14381
+ deviceId: number(),
14382
+ zoneId: string()
14383
+ }), _void(), {
14384
+ kind: "mutation",
14385
+ auth: "admin"
14386
+ }),
14387
+ updateZone: method(object({
14388
+ deviceId: number(),
14389
+ zone: ZoneSchema
14390
+ }), _void(), {
14391
+ kind: "mutation",
14392
+ auth: "admin"
14393
+ })
14394
+ },
14395
+ /**
14396
+ * Runtime-state slice — the live zone catalogue mirrored by the
14397
+ * orchestrator on every CRUD mutation. Consumers read via
14398
+ * `device.state.zones.value` / `.watch(...)` without round-tripping
14399
+ * the cap, and the codegen DeviceProxy auto-wires the reactive
14400
+ * handle. Slice shape is `{ zones: Zone[] }` so future extensions
14401
+ * (e.g. zone groupings) can sit alongside the polygon list.
14402
+ */
14403
+ runtimeState: object({ zones: array(ZoneSchema).readonly() })
14404
+ };
12949
14405
  /**
12950
14406
  * A bounding box in NORMALIZED [0,1] frame coordinates for `getNativeCrop`. The
12951
14407
  * decode worker resolves it against the RETAINED native frame's real pixel dims,
@@ -13361,7 +14817,19 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
13361
14817
  parent: DetailParentSchema,
13362
14818
  steps: array(string()).optional()
13363
14819
  }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
13364
- object({
14820
+ /**
14821
+ * Hardware / firmware motion sensor cap — binary detected state plus
14822
+ * a timestamp of the last observation. Distinct from
14823
+ * `motion-detection.cap.ts` which owns the LOCAL ML motion pipeline;
14824
+ * `motion` is the lightweight readout from on-camera motion (Reolink
14825
+ * `GetMdState`, Baichuan push `type: motion`, ONVIF analytics).
14826
+ *
14827
+ * Native-motion providers also fan out to `detection.camera-native`
14828
+ * with `source: 'onboard'` so cross-cutting system services
14829
+ * (alert-center, advanced-notifier) can subscribe once and receive
14830
+ * motion from every camera.
14831
+ */
14832
+ var MotionStatusSchema = object({
13365
14833
  detected: boolean(),
13366
14834
  /** Ms epoch of the last detected-true observation. Null if never detected. */
13367
14835
  lastDetectedAt: number().nullable(),
@@ -13372,32 +14840,137 @@ object({
13372
14840
  */
13373
14841
  autoClearAfterMs: number().nullable()
13374
14842
  });
13375
- object({
14843
+ /**
14844
+ * Payload of `motion.onMotionChanged` event + the corresponding bus
14845
+ * event `EventCategory.MotionOnMotionChanged`. Single source of truth
14846
+ * — both the cap event surface and the bus payload type alias to this
14847
+ * schema.
14848
+ */
14849
+ var MotionOnMotionChangedDataSchema = object({
13376
14850
  deviceId: number(),
13377
14851
  detected: boolean(),
13378
14852
  timestamp: number(),
13379
14853
  source: MotionSourceEnum,
13380
14854
  regions: array(MotionRegionSchema).readonly().optional()
13381
14855
  });
13382
- DeviceType.Camera, DeviceType.Sensor, method(object({ deviceId: number() }), boolean());
13383
- object({
14856
+ var motionCapability = {
14857
+ name: "motion",
14858
+ scope: "device",
14859
+ mode: "singleton",
14860
+ /**
14861
+ * Providers register per-device natives via `ctx.registerNativeCap`
14862
+ * (Hikvision/Reolink/Amcrest/Wyze/HA/Homematic/Alexa/Matter) — there is
14863
+ * NO system singleton provider. Without this flag `resolveCapMount`
14864
+ * derived `{ kind: 'singleton' }`, so `motion.getStatus`/`isDetected`
14865
+ * resolved via `registry.getSingleton('motion')` (always null) and every
14866
+ * call 412'd "provider not available" while bindings listed a live
14867
+ * `motion` native (2026-08-02). The flag routes the router through
14868
+ * `requireDeviceScoped` → `getProviderForDevice`, like `motion-trigger`,
14869
+ * `snapshot` and every other per-device native cap.
14870
+ */
14871
+ deviceNative: true,
14872
+ deviceTypes: [DeviceType.Camera, DeviceType.Sensor],
14873
+ methods: {
14874
+ /**
14875
+ * Pull the current motion state synchronously. Convenience shortcut
14876
+ * for consumers that don't need the full status object; equivalent
14877
+ * to `getStatus()?.detected ?? false`. Will likely be folded into
14878
+ * `getStatus` once the auto-injected status surface lands in every
14879
+ * consumer.
14880
+ */
14881
+ isDetected: method(object({ deviceId: number() }), boolean()) },
14882
+ events: {
14883
+ /**
14884
+ * Fires every time the runner transitions a camera between
14885
+ * `watching` and `active` phases. `source` carries which motion
14886
+ * path drove the transition; `regions` is populated only for
14887
+ * `source: 'analyzer'` (frame-diff regions from the ML motion
14888
+ * detector) — onboard sources don't carry per-frame regions
14889
+ * here (camera-provided zones / AI metadata live in dedicated
14890
+ * channels: `detection.camera-native`, future zone capability).
14891
+ *
14892
+ * Consumers that want all motion pushes (even with `detected`
14893
+ * unchanged) should subscribe to the status subscription via
14894
+ * `device-manager.subscribeDeviceStatusAggregate` instead.
14895
+ */
14896
+ onMotionChanged: { data: MotionOnMotionChangedDataSchema } },
14897
+ status: {
14898
+ schema: MotionStatusSchema,
14899
+ kind: "push"
14900
+ },
14901
+ /**
14902
+ * Runtime-state slice — the last observed motion snapshot, mirrored
14903
+ * by the kernel and readable cross-process via
14904
+ * `device.state.motion.value`. Reads never invoke the provider, so
14905
+ * UIs and other addons can poll the cached state safely.
14906
+ */
14907
+ runtimeState: MotionStatusSchema
14908
+ };
14909
+ /**
14910
+ * Motion-trigger toggle for accessory devices.
14911
+ *
14912
+ * "Motion trigger" means: when the parent camera detects motion, the
14913
+ * accessory activates automatically. The cap exposes a single boolean
14914
+ * — `enabled` — that drivers map to the vendor-specific firmware
14915
+ * action (Reolink: `setSirenOnMotion`, `setFloodlightOnMotion`; ONVIF
14916
+ * relay: schedule binding; …).
14917
+ *
14918
+ * The accessory still has its own `switch` cap for direct on/off; this
14919
+ * cap is independent. Toggling motion-trigger on does NOT necessarily
14920
+ * toggle the switch on — it just instructs the firmware to flip the
14921
+ * switch when motion fires.
14922
+ *
14923
+ * Driver-specific knobs (motion duration window, brightness while
14924
+ * triggered, schedule windows) live in the device's
14925
+ * `getSettingsUISchema()` instead of bloating this cap — same
14926
+ * principle as `switch` and `brightness` keeping their surface
14927
+ * minimal.
14928
+ */
14929
+ var MotionTriggerStatusSchema = object({
13384
14930
  enabled: boolean(),
13385
14931
  /** Ms epoch of the last operator-driven change. */
13386
14932
  lastChangedAt: number()
13387
- }).extend({
14933
+ });
14934
+ /**
14935
+ * Persistent slice mirrored across restarts. The provider writes here
14936
+ * on every successful firmware fetch / setMotionTrigger push; the cap
14937
+ * router and admin-ui hero read straight from this snapshot via
14938
+ * `device.state.motionTrigger.value` instead of re-issuing a firmware
14939
+ * round-trip on every UI mount. `lastFetchedAt` lets the framework
14940
+ * helper (`createRuntimeStateBridge`) stale-check before deciding
14941
+ * whether to refresh from the camera.
14942
+ */
14943
+ var MotionTriggerRuntimeStateSchema = MotionTriggerStatusSchema.extend({
13388
14944
  /** Ms epoch of the last successful camera fetch (0 = never). */
13389
14945
  lastFetchedAt: number() });
13390
- DeviceType.Light, DeviceType.Siren, DeviceType.Switch, method(object({
13391
- deviceId: number().int().nonnegative(),
13392
- enabled: boolean()
13393
- }), _void(), {
13394
- kind: "mutation",
13395
- auth: "admin"
13396
- }), object({
13397
- deviceId: number(),
13398
- enabled: boolean(),
13399
- lastChangedAt: number()
13400
- });
14946
+ var motionTriggerCapability = {
14947
+ name: "motion-trigger",
14948
+ scope: "device",
14949
+ deviceNative: true,
14950
+ mode: "singleton",
14951
+ deviceTypes: [
14952
+ DeviceType.Light,
14953
+ DeviceType.Siren,
14954
+ DeviceType.Switch
14955
+ ],
14956
+ methods: { setMotionTrigger: method(object({
14957
+ deviceId: number().int().nonnegative(),
14958
+ enabled: boolean()
14959
+ }), _void(), {
14960
+ kind: "mutation",
14961
+ auth: "admin"
14962
+ }) },
14963
+ events: { onMotionTriggerChanged: { data: object({
14964
+ deviceId: number(),
14965
+ enabled: boolean(),
14966
+ lastChangedAt: number()
14967
+ }) } },
14968
+ status: {
14969
+ schema: MotionTriggerStatusSchema,
14970
+ kind: "command-driven"
14971
+ },
14972
+ runtimeState: MotionTriggerRuntimeStateSchema
14973
+ };
13401
14974
  /**
13402
14975
  * Motion-zones share the same MaskShape vocabulary as privacy-mask — the
13403
14976
  * on-camera motion-detection mask is a single `grid` region (a row-major
@@ -13410,7 +14983,9 @@ var MotionZoneRegionSchema = object({
13410
14983
  enabled: boolean(),
13411
14984
  shape: MaskGridShapeSchema
13412
14985
  });
13413
- object({
14986
+ /** Current on-camera motion-detection state — master enable + sensitivity +
14987
+ * the grid region(s). */
14988
+ var MotionZoneStatusSchema = object({
13414
14989
  enabled: boolean(),
13415
14990
  sensitivity: number(),
13416
14991
  /** Grid region(s). Today exactly one `grid` shape. */
@@ -13435,13 +15010,34 @@ var MotionZonePatchSchema = object({
13435
15010
  sensitivity: number().optional(),
13436
15011
  regions: array(MotionZoneRegionSchema).optional()
13437
15012
  });
13438
- DeviceType.Camera, method(object({ deviceId: number() }), MotionZoneOptionsSchema), method(object({
13439
- deviceId: number(),
13440
- patch: MotionZonePatchSchema
13441
- }), _void(), {
13442
- kind: "mutation",
13443
- auth: "admin"
13444
- });
15013
+ var motionZonesCapability = {
15014
+ name: "motion-zones",
15015
+ scope: "device",
15016
+ deviceNative: true,
15017
+ mode: "singleton",
15018
+ deviceTypes: [DeviceType.Camera],
15019
+ deviceConfig: { ui: {
15020
+ kind: "widget",
15021
+ widgetId: "host/motion-zones-grid",
15022
+ tab: "motion",
15023
+ label: "Motion Zones"
15024
+ } },
15025
+ methods: {
15026
+ getOptions: method(object({ deviceId: number() }), MotionZoneOptionsSchema),
15027
+ setZone: method(object({
15028
+ deviceId: number(),
15029
+ patch: MotionZonePatchSchema
15030
+ }), _void(), {
15031
+ kind: "mutation",
15032
+ auth: "admin"
15033
+ })
15034
+ },
15035
+ status: {
15036
+ schema: MotionZoneStatusSchema,
15037
+ kind: "poll"
15038
+ },
15039
+ runtimeState: MotionZoneStatusSchema
15040
+ };
13445
15041
  /**
13446
15042
  * On-camera AI object detection cap. Surfaces per-device the classes
13447
15043
  * the firmware can detect and the last-seen instance of each. The
@@ -13468,7 +15064,7 @@ var NativeDetectionSchema = object({
13468
15064
  /** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
13469
15065
  confidence: number().min(0).max(1).optional()
13470
15066
  });
13471
- object({
15067
+ var NativeObjectDetectionStatusSchema = object({
13472
15068
  /**
13473
15069
  * Last observed instance per class. Missing entries mean the class
13474
15070
  * is supported but nothing has been seen since the provider started.
@@ -13489,19 +15085,33 @@ object({
13489
15085
  * churn the tracker, so forwarding stays off until the operator enables it.
13490
15086
  */
13491
15087
  enabled: boolean()
13492
- }).extend({
15088
+ });
15089
+ var NativeObjectDetectionRuntimeStateSchema = NativeObjectDetectionStatusSchema.extend({
13493
15090
  /** Required by createRuntimeStateBridge — epoch ms of last refresh. */
13494
15091
  lastFetchedAt: number() });
13495
- DeviceType.Camera, method(object({
13496
- deviceId: number(),
13497
- enabled: boolean()
13498
- }), _void(), {
13499
- kind: "mutation",
13500
- auth: "admin"
13501
- }), object({
13502
- deviceId: number(),
13503
- detection: NativeDetectionSchema
13504
- });
15092
+ var nativeObjectDetectionCapability = {
15093
+ name: "native-object-detection",
15094
+ scope: "device",
15095
+ deviceNative: true,
15096
+ mode: "singleton",
15097
+ deviceTypes: [DeviceType.Camera],
15098
+ methods: { setEnabled: method(object({
15099
+ deviceId: number(),
15100
+ enabled: boolean()
15101
+ }), _void(), {
15102
+ kind: "mutation",
15103
+ auth: "admin"
15104
+ }) },
15105
+ events: { onDetected: { data: object({
15106
+ deviceId: number(),
15107
+ detection: NativeDetectionSchema
15108
+ }) } },
15109
+ status: {
15110
+ schema: NativeObjectDetectionStatusSchema,
15111
+ kind: "push"
15112
+ },
15113
+ runtimeState: NativeObjectDetectionRuntimeStateSchema
15114
+ };
13505
15115
  /**
13506
15116
  * Notification delivery cap. Models HA `notify.<service>` plus future
13507
15117
  * native targets (Telegram / Discord / ntfy / SMTP, …). Designed at
@@ -13553,7 +15163,7 @@ var NotifierSupportsSchema = object({
13553
15163
  * Pair with `DeviceFeature.NotifierRecipients`. */
13554
15164
  recipients: boolean()
13555
15165
  });
13556
- object({
15166
+ var NotifierStatusSchema = object({
13557
15167
  /** Ms epoch of the most recent successful send. 0 if none yet. */
13558
15168
  lastSentAt: number(),
13559
15169
  /** Failure description from the most recent send attempt. Null on
@@ -13594,23 +15204,67 @@ var NotifierSendResultSchema = object({
13594
15204
  /** Ms epoch when the notifier accepted the send (not when delivered). */
13595
15205
  acceptedAt: number()
13596
15206
  });
13597
- DeviceType.Notifier, method(NotifierSendInputSchema, NotifierSendResultSchema, {
13598
- kind: "mutation",
13599
- auth: "admin"
13600
- }), method(object({
13601
- deviceId: number().int().nonnegative(),
13602
- notificationId: string().min(1)
13603
- }), _void(), {
13604
- kind: "mutation",
13605
- auth: "admin"
13606
- }), object({
13607
- deviceId: number(),
13608
- notificationId: string(),
13609
- success: boolean(),
13610
- error: string().nullable(),
13611
- acceptedAt: number()
13612
- });
13613
- object({
15207
+ var notifierCapability = {
15208
+ name: "notifier",
15209
+ scope: "device",
15210
+ deviceNative: true,
15211
+ mode: "singleton",
15212
+ deviceTypes: [DeviceType.Notifier],
15213
+ methods: {
15214
+ send: method(NotifierSendInputSchema, NotifierSendResultSchema, {
15215
+ kind: "mutation",
15216
+ auth: "admin"
15217
+ }),
15218
+ cancel: method(object({
15219
+ deviceId: number().int().nonnegative(),
15220
+ notificationId: string().min(1)
15221
+ }), _void(), {
15222
+ kind: "mutation",
15223
+ auth: "admin"
15224
+ })
15225
+ },
15226
+ events: {
15227
+ /**
15228
+ * Emitted after every send attempt — success or failure. Subscribers
15229
+ * (admin UI history pane, automation engines, retry workers) react
15230
+ * without polling the provider's `lastSentAt`.
15231
+ */
15232
+ onSent: { data: object({
15233
+ deviceId: number(),
15234
+ notificationId: string(),
15235
+ success: boolean(),
15236
+ error: string().nullable(),
15237
+ acceptedAt: number()
15238
+ }) } },
15239
+ status: {
15240
+ schema: NotifierStatusSchema,
15241
+ kind: "command-driven"
15242
+ },
15243
+ /**
15244
+ * Runtime-state slice — diagnostics + supports matrix. UI compose
15245
+ * form reads `supports` to gate optional fields; history pane reads
15246
+ * `lastSentAt` / `lastError` / `queueDepth`.
15247
+ */
15248
+ runtimeState: NotifierStatusSchema
15249
+ };
15250
+ /**
15251
+ * Generic numeric sensor — last-resort fallback when no typed numeric
15252
+ * cap fits (HA `sensor` whose `device_class` we don't have a typed cap
15253
+ * for: water flow, distance, weight, frequency, signal strength, …).
15254
+ *
15255
+ * The `unit` and `precision` fields carry the live display metadata that
15256
+ * the upstream source provides with each state push (HA
15257
+ * `attributes.unit_of_measurement` / `attributes.suggested_display_precision`).
15258
+ * The UI reads them directly from the slice — they are the single source
15259
+ * of truth for generic numeric rendering and do NOT live on `sourceInfo`.
15260
+ *
15261
+ * Prefer the typed alternatives (`temperature-sensor`, `humidity-sensor`,
15262
+ * `ambient-light-sensor`, `pressure-sensor`, `power-meter`,
15263
+ * `air-quality-sensor`, `battery`) when the metric matches — export
15264
+ * adapters render those with the right service / category. Generic
15265
+ * numeric values land here.
15266
+ */
15267
+ var NumericSensorStatusSchema = object({
13614
15268
  value: number(),
13615
15269
  /** Display unit-of-measurement (e.g. 'dBm', 's', 'rpm', 'steps').
13616
15270
  * Populated live from the upstream source on each state push.
@@ -13623,7 +15277,19 @@ object({
13623
15277
  /** Ms epoch when the slice was last updated. */
13624
15278
  lastFetchedAt: number()
13625
15279
  });
13626
- DeviceType.Sensor;
15280
+ var numericSensorCapability = {
15281
+ name: "numeric-sensor",
15282
+ scope: "device",
15283
+ deviceNative: true,
15284
+ mode: "singleton",
15285
+ deviceTypes: [DeviceType.Sensor],
15286
+ methods: {},
15287
+ status: {
15288
+ schema: NumericSensorStatusSchema,
15289
+ kind: "push"
15290
+ },
15291
+ runtimeState: NumericSensorStatusSchema
15292
+ };
13627
15293
  /**
13628
15294
  * Feeder connectivity / power status — mirrors the HA petkit device-status
13629
15295
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
@@ -13635,7 +15301,7 @@ var PetFeederDeviceStatusSchema = _enum([
13635
15301
  "on_batteries"
13636
15302
  ]);
13637
15303
  var gramsPortion = number().int().min(4).max(200);
13638
- object({
15304
+ var PetFeederStatusSchema = object({
13639
15305
  /** Food currently in the bowl (grams). Null when the device has not
13640
15306
  * reported a reading yet. On dual-hopper models this is the combined
13641
15307
  * bowl reading; per-hopper levels live in `food1`/`food2`. */
@@ -13680,58 +15346,117 @@ object({
13680
15346
  /** Ms epoch when the slice was last refreshed from the cloud. */
13681
15347
  lastFetchedAt: number()
13682
15348
  });
13683
- DeviceType.PetFeeder, method(object({
13684
- deviceId: number().int().nonnegative(),
13685
- grams: gramsPortion.optional(),
13686
- hopper1: gramsPortion.optional(),
13687
- hopper2: gramsPortion.optional()
13688
- }), _void(), {
13689
- kind: "mutation",
13690
- auth: "admin"
13691
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13692
- kind: "mutation",
13693
- auth: "admin"
13694
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13695
- kind: "mutation",
13696
- auth: "admin"
13697
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13698
- kind: "mutation",
13699
- auth: "admin"
13700
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13701
- kind: "mutation",
13702
- auth: "admin"
13703
- }), method(object({
13704
- deviceId: number().int().nonnegative(),
13705
- soundId: number().int().nonnegative()
13706
- }), _void(), {
13707
- kind: "mutation",
13708
- auth: "admin"
13709
- }), method(object({
13710
- deviceId: number().int().nonnegative(),
13711
- on: boolean()
13712
- }), _void(), {
13713
- kind: "mutation",
13714
- auth: "admin"
13715
- }), method(object({
13716
- deviceId: number().int().nonnegative(),
13717
- on: boolean()
13718
- }), _void(), {
13719
- kind: "mutation",
13720
- auth: "admin"
13721
- }), method(object({
13722
- deviceId: number().int().nonnegative(),
13723
- on: boolean()
13724
- }), _void(), {
13725
- kind: "mutation",
13726
- auth: "admin"
13727
- }), method(object({
13728
- deviceId: number().int().nonnegative(),
13729
- level: number().int().nonnegative()
13730
- }), _void(), {
13731
- kind: "mutation",
13732
- auth: "admin"
13733
- });
13734
- object({
15349
+ var petFeederCapability = {
15350
+ name: "pet-feeder",
15351
+ scope: "device",
15352
+ deviceNative: true,
15353
+ mode: "singleton",
15354
+ deviceTypes: [DeviceType.PetFeeder],
15355
+ methods: {
15356
+ /**
15357
+ * Dispense food now. Single-hopper feeders take `grams`; dual-hopper
15358
+ * feeders (D4S/D4SH) accept `hopper1`/`hopper2` to target one or both
15359
+ * hoppers. All portions honour the 4–200 g hardware range. At least
15360
+ * one of the three must be present — the provider rejects an empty
15361
+ * request.
15362
+ */
15363
+ feed: method(object({
15364
+ deviceId: number().int().nonnegative(),
15365
+ grams: gramsPortion.optional(),
15366
+ hopper1: gramsPortion.optional(),
15367
+ hopper2: gramsPortion.optional()
15368
+ }), _void(), {
15369
+ kind: "mutation",
15370
+ auth: "admin"
15371
+ }),
15372
+ /** Cancel an in-progress manual feed. */
15373
+ cancelFeed: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15374
+ kind: "mutation",
15375
+ auth: "admin"
15376
+ }),
15377
+ /** Reset the desiccant "days remaining" counter after replacing it. */
15378
+ resetDesiccant: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15379
+ kind: "mutation",
15380
+ auth: "admin"
15381
+ }),
15382
+ /** Mark a hopper as refilled (D4H/D4S/D4SH). */
15383
+ markFoodReplenished: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15384
+ kind: "mutation",
15385
+ auth: "admin"
15386
+ }),
15387
+ /** Call the pet with the recorded prompt (D3). */
15388
+ callPet: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15389
+ kind: "mutation",
15390
+ auth: "admin"
15391
+ }),
15392
+ /** Play a stored sound by id (D3 / D4H / D4SH). */
15393
+ playSound: method(object({
15394
+ deviceId: number().int().nonnegative(),
15395
+ soundId: number().int().nonnegative()
15396
+ }), _void(), {
15397
+ kind: "mutation",
15398
+ auth: "admin"
15399
+ }),
15400
+ /** Toggle the child-lock (manual-lock) setting. */
15401
+ setChildLock: method(object({
15402
+ deviceId: number().int().nonnegative(),
15403
+ on: boolean()
15404
+ }), _void(), {
15405
+ kind: "mutation",
15406
+ auth: "admin"
15407
+ }),
15408
+ /** Toggle the front indicator light. */
15409
+ setIndicatorLight: method(object({
15410
+ deviceId: number().int().nonnegative(),
15411
+ on: boolean()
15412
+ }), _void(), {
15413
+ kind: "mutation",
15414
+ auth: "admin"
15415
+ }),
15416
+ /** Toggle the dispense chime. */
15417
+ setFeedSound: method(object({
15418
+ deviceId: number().int().nonnegative(),
15419
+ on: boolean()
15420
+ }), _void(), {
15421
+ kind: "mutation",
15422
+ auth: "admin"
15423
+ }),
15424
+ /** Set the speaker / prompt volume level. */
15425
+ setVolume: method(object({
15426
+ deviceId: number().int().nonnegative(),
15427
+ level: number().int().nonnegative()
15428
+ }), _void(), {
15429
+ kind: "mutation",
15430
+ auth: "admin"
15431
+ })
15432
+ },
15433
+ status: {
15434
+ schema: PetFeederStatusSchema,
15435
+ kind: "poll"
15436
+ },
15437
+ /**
15438
+ * Runtime-state slice — mirrored by the kernel. UI feeder cards read
15439
+ * the full slice via `device.state.petFeeder.value` and refresh on
15440
+ * every poll without re-querying the provider.
15441
+ */
15442
+ runtimeState: PetFeederStatusSchema
15443
+ };
15444
+ /**
15445
+ * Multi-metric electrical meter. One slice can carry any combination
15446
+ * of instantaneous power (W), cumulative energy (kWh), voltage (V),
15447
+ * and current (A) — all fields optional so a single-metric source
15448
+ * (HA `sensor` with `device_class: power`) populates only what it
15449
+ * has and aggregators (energy dashboards, billing exports) compose
15450
+ * across providers without needing one cap per metric.
15451
+ *
15452
+ * Trade-off acknowledged: a HomeKit export that wants to surface
15453
+ * "power" and "energy" as separate services has to crack open the
15454
+ * slice and emit two characteristics from one cap. Worth it to avoid
15455
+ * exploding the cap catalog with `power-w` / `energy-kwh` /
15456
+ * `voltage-v` / `current-a` quartets that always travel together
15457
+ * in real-world devices.
15458
+ */
15459
+ var PowerMeterStatusSchema = object({
13735
15460
  /** Instantaneous power draw in watts. */
13736
15461
  watts: number().optional(),
13737
15462
  /** Cumulative energy in kilowatt-hours since the meter was reset. */
@@ -13758,7 +15483,19 @@ object({
13758
15483
  * auto-formatting when absent. */
13759
15484
  precision: number().int().min(0).max(10).optional()
13760
15485
  });
13761
- DeviceType.Sensor;
15486
+ var powerMeterCapability = {
15487
+ name: "power-meter",
15488
+ scope: "device",
15489
+ deviceNative: true,
15490
+ mode: "singleton",
15491
+ deviceTypes: [DeviceType.Sensor],
15492
+ methods: {},
15493
+ status: {
15494
+ schema: PowerMeterStatusSchema,
15495
+ kind: "push"
15496
+ },
15497
+ runtimeState: PowerMeterStatusSchema
15498
+ };
13762
15499
  /**
13763
15500
  * Presence cap. Models HA `person.*` and `device_tracker.*` entities
13764
15501
  * on `DeviceType.Presence`. Read-only — no setters: presence is
@@ -13783,7 +15520,7 @@ var GpsLocationSchema = object({
13783
15520
  /** Reported accuracy in meters (lower = better). */
13784
15521
  accuracyMeters: number().nonnegative()
13785
15522
  });
13786
- object({
15523
+ var PresenceStatusSchema = object({
13787
15524
  /** `home` / `not_home` / any user-defined zone name. */
13788
15525
  state: string(),
13789
15526
  /** Optional textual location label (zone name, city, address). Null
@@ -13797,8 +15534,34 @@ object({
13797
15534
  /** Ms epoch when the slice was last updated. */
13798
15535
  lastChangedAt: number()
13799
15536
  });
13800
- DeviceType.Presence;
13801
- object({
15537
+ var presenceCapability = {
15538
+ name: "presence",
15539
+ scope: "device",
15540
+ deviceNative: true,
15541
+ mode: "singleton",
15542
+ deviceTypes: [DeviceType.Presence],
15543
+ methods: {},
15544
+ status: {
15545
+ schema: PresenceStatusSchema,
15546
+ kind: "push"
15547
+ },
15548
+ /**
15549
+ * Runtime-state slice — mirrored by the kernel. UI presence card
15550
+ * reads `state` + `location` for the text label; iff `gps !== null`
15551
+ * the map pin is rendered (use `DeviceFeature.PresenceGps` for the
15552
+ * pre-fetch fast-path check).
15553
+ */
15554
+ runtimeState: PresenceStatusSchema
15555
+ };
15556
+ /**
15557
+ * Atmospheric pressure reading in hectopascals. Drives Home Assistant
15558
+ * `sensor` entries with `device_class: pressure`.
15559
+ *
15560
+ * Unit normalisation: hPa. The canonical display unit (`hPa`) is a
15561
+ * descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
15562
+ * `sourceInfo`.
15563
+ */
15564
+ var PressureSensorStatusSchema = object({
13802
15565
  /** Current pressure in hPa. */
13803
15566
  hpa: number(),
13804
15567
  /** Ms epoch when the slice was last updated. */
@@ -13813,7 +15576,19 @@ object({
13813
15576
  * auto-formatting when absent. */
13814
15577
  precision: number().int().min(0).max(10).optional()
13815
15578
  });
13816
- DeviceType.Sensor;
15579
+ var pressureSensorCapability = {
15580
+ name: "pressure-sensor",
15581
+ scope: "device",
15582
+ deviceNative: true,
15583
+ mode: "singleton",
15584
+ deviceTypes: [DeviceType.Sensor],
15585
+ methods: {},
15586
+ status: {
15587
+ schema: PressureSensorStatusSchema,
15588
+ kind: "push"
15589
+ },
15590
+ runtimeState: PressureSensorStatusSchema
15591
+ };
13817
15592
  /**
13818
15593
  * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
13819
15594
  * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
@@ -13832,7 +15607,8 @@ var PrivacyMaskRegionSchema = object({
13832
15607
  enabled: boolean(),
13833
15608
  shape: PrivacyMaskShapeSchema
13834
15609
  });
13835
- object({
15610
+ /** Current on-camera privacy-mask state — master enable + zones. */
15611
+ var PrivacyMaskStatusSchema = object({
13836
15612
  enabled: boolean(),
13837
15613
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
13838
15614
  regions: array(PrivacyMaskRegionSchema),
@@ -13852,13 +15628,34 @@ var PrivacyMaskPatchSchema = object({
13852
15628
  enabled: boolean().optional(),
13853
15629
  regions: array(PrivacyMaskRegionSchema).optional()
13854
15630
  });
13855
- DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSchema), method(object({
13856
- deviceId: number(),
13857
- patch: PrivacyMaskPatchSchema
13858
- }), _void(), {
13859
- kind: "mutation",
13860
- auth: "admin"
13861
- });
15631
+ var privacyMaskCapability = {
15632
+ name: "privacy-mask",
15633
+ scope: "device",
15634
+ deviceNative: true,
15635
+ mode: "singleton",
15636
+ deviceTypes: [DeviceType.Camera],
15637
+ deviceConfig: { ui: {
15638
+ kind: "widget",
15639
+ widgetId: "host/privacy-mask-grid",
15640
+ tab: "image",
15641
+ label: "Privacy Mask"
15642
+ } },
15643
+ methods: {
15644
+ getOptions: method(object({ deviceId: number() }), PrivacyMaskOptionsSchema),
15645
+ setMask: method(object({
15646
+ deviceId: number(),
15647
+ patch: PrivacyMaskPatchSchema
15648
+ }), _void(), {
15649
+ kind: "mutation",
15650
+ auth: "admin"
15651
+ })
15652
+ },
15653
+ status: {
15654
+ schema: PrivacyMaskStatusSchema,
15655
+ kind: "poll"
15656
+ },
15657
+ runtimeState: PrivacyMaskStatusSchema
15658
+ };
13862
15659
  var PtzAutotrackSettingsSchema = object({
13863
15660
  targetType: string().describe("Vendor target string (people/vehicle/pet); empty = camera default"),
13864
15661
  stopDelaySeconds: number().int().min(0).max(300),
@@ -13898,19 +15695,85 @@ var PtzAutotrackStatusSchema = object({
13898
15695
  */
13899
15696
  supportedTargetTypes: array(PtzAutotrackTargetOptionSchema)
13900
15697
  });
13901
- PtzAutotrackStatusSchema.extend({
15698
+ /**
15699
+ * Runtime-state slice owned by this cap. Persists the last-known
15700
+ * camera-derived snapshot across restarts, so cold-start callers
15701
+ * have a meaningful baseline even before the first refresh round-trip.
15702
+ *
15703
+ * Adds `lastFetchedAt` on top of the status shape so the framework
15704
+ * (or the provider) can stale-check before deciding whether to
15705
+ * round-trip the camera again.
15706
+ */
15707
+ var PtzAutotrackRuntimeStateSchema = PtzAutotrackStatusSchema.extend({
13902
15708
  /** Ms epoch of the last successful camera fetch (0 = never). */
13903
15709
  lastFetchedAt: number() });
13904
- DeviceType.Camera, method(object({ deviceId: number() }), PtzAutotrackStatusSchema), method(object({
13905
- deviceId: number(),
13906
- enabled: boolean()
13907
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzAutotrackSettingsSchema.nullable()), method(object({
13908
- deviceId: number(),
13909
- settings: PtzAutotrackSettingsSchema.partial()
13910
- }), _void(), { kind: "mutation" }), object({
13911
- deviceId: number(),
13912
- status: PtzAutotrackStatusSchema
13913
- });
15710
+ var ptzAutotrackCapability = {
15711
+ name: "ptz-autotrack",
15712
+ scope: "device",
15713
+ deviceNative: true,
15714
+ mode: "singleton",
15715
+ deviceTypes: [DeviceType.Camera],
15716
+ deviceConfig: { ui: {
15717
+ kind: "widget",
15718
+ widgetId: "host/ptz-autotrack",
15719
+ tab: "ptz",
15720
+ topTab: true,
15721
+ label: "Auto-Tracking",
15722
+ order: 5
15723
+ } },
15724
+ methods: {
15725
+ /**
15726
+ * Read the current on/off state + last-applied settings.
15727
+ * Drivers may serve from a cache + refresh on a schedule —
15728
+ * callers should NOT poll faster than ~5s.
15729
+ */
15730
+ getStatus: method(object({ deviceId: number() }), PtzAutotrackStatusSchema),
15731
+ /**
15732
+ * Arm or disarm autotrack. Resolves once the camera has
15733
+ * acknowledged the transition; the `onStatusChanged` event
15734
+ * fires shortly after with the live state.
15735
+ */
15736
+ setEnabled: method(object({
15737
+ deviceId: number(),
15738
+ enabled: boolean()
15739
+ }), _void(), { kind: "mutation" }),
15740
+ /**
15741
+ * Read settings (target type + delays). Mirrors the
15742
+ * `getStatus().currentSettings` payload but exposes it as a
15743
+ * standalone read for callers that want settings without the
15744
+ * status surface around them. `null` until the driver has
15745
+ * harvested the firmware state at least once.
15746
+ */
15747
+ getSettings: method(object({ deviceId: number() }), PtzAutotrackSettingsSchema.nullable()),
15748
+ /**
15749
+ * Update one or more settings. Partial — keys that aren't
15750
+ * supplied keep their last persisted value. The driver is
15751
+ * responsible for clamping vendor-specific ranges if narrower
15752
+ * than the cross-vendor max declared on the schema.
15753
+ */
15754
+ setSettings: method(object({
15755
+ deviceId: number(),
15756
+ settings: PtzAutotrackSettingsSchema.partial()
15757
+ }), _void(), { kind: "mutation" })
15758
+ },
15759
+ events: { onStatusChanged: { data: object({
15760
+ deviceId: number(),
15761
+ status: PtzAutotrackStatusSchema
15762
+ }) } },
15763
+ status: {
15764
+ schema: PtzAutotrackStatusSchema,
15765
+ kind: "command-driven"
15766
+ },
15767
+ /**
15768
+ * Persistent slice mirrored across restarts. The provider writes
15769
+ * here on every successful firmware fetch / settings push;
15770
+ * `getStatus` and `getSettings` then read this snapshot synchronously
15771
+ * with a stale-driven async refresh in the background. Keeps the
15772
+ * fetch / cache / fallback logic out of the four cap methods —
15773
+ * they become trampolines over `runtimeState`.
15774
+ */
15775
+ runtimeState: PtzAutotrackRuntimeStateSchema
15776
+ };
13914
15777
  /**
13915
15778
  * scene-monitor — device-scoped reference-region state cap. An operator marks
13916
15779
  * a rect ROI on a camera frame and names one or more states; the engine
@@ -13980,72 +15843,108 @@ var SceneMonitorStatusSchema = object({
13980
15843
  monitors: array(SceneMonitorSchema),
13981
15844
  lastFetchedAt: number()
13982
15845
  });
13983
- DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSchema), method(object({
13984
- deviceId: number(),
13985
- label: string(),
13986
- roi: MaskRectShapeSchema,
13987
- check: SceneCheckSchema,
13988
- triggerMode: _enum([
13989
- "periodic",
13990
- "on-motion",
13991
- "both"
13992
- ]).optional(),
13993
- checkIntervalSec: number().optional()
13994
- }), SceneMonitorSchema, {
13995
- kind: "mutation",
13996
- auth: "admin"
13997
- }), method(object({
13998
- deviceId: number(),
13999
- monitorId: string(),
14000
- patch: object({
14001
- label: string().optional(),
14002
- roi: MaskRectShapeSchema.optional(),
14003
- enabled: boolean().optional(),
14004
- triggerMode: _enum([
14005
- "periodic",
14006
- "on-motion",
14007
- "both"
14008
- ]).optional(),
14009
- checkIntervalSec: number().optional(),
14010
- check: SceneCheckSchema.optional()
14011
- })
14012
- }), _void(), {
14013
- kind: "mutation",
14014
- auth: "admin"
14015
- }), method(object({
14016
- deviceId: number(),
14017
- monitorId: string()
14018
- }), _void(), {
14019
- kind: "mutation",
14020
- auth: "admin"
14021
- }), method(object({
14022
- deviceId: number(),
14023
- monitorId: string(),
14024
- stateId: string().optional(),
14025
- label: string().optional(),
14026
- condition: SceneConditionSchema.optional()
14027
- }), object({
14028
- stateId: string(),
14029
- referenceCount: number()
14030
- }), {
14031
- kind: "mutation",
14032
- auth: "admin"
14033
- }), method(object({
14034
- deviceId: number(),
14035
- monitorId: string(),
14036
- stateId: string(),
14037
- index: number()
14038
- }), _void(), {
14039
- kind: "mutation",
14040
- auth: "admin"
14041
- }), method(object({
14042
- deviceId: number(),
14043
- monitorId: string()
14044
- }), _void(), {
14045
- kind: "mutation",
14046
- auth: "admin"
14047
- });
14048
- object({
15846
+ var sceneMonitorCapability = {
15847
+ name: "scene-monitor",
15848
+ scope: "device",
15849
+ mode: "singleton",
15850
+ kind: "wrapper",
15851
+ defaultActive: true,
15852
+ deviceTypes: [DeviceType.Camera],
15853
+ deviceConfig: { ui: {
15854
+ kind: "widget",
15855
+ widgetId: "host/scene-monitor-editor",
15856
+ tab: "scenes",
15857
+ label: "Scenes"
15858
+ } },
15859
+ methods: {
15860
+ listScenes: method(object({ deviceId: number() }), SceneMonitorStatusSchema),
15861
+ createScene: method(object({
15862
+ deviceId: number(),
15863
+ label: string(),
15864
+ roi: MaskRectShapeSchema,
15865
+ check: SceneCheckSchema,
15866
+ triggerMode: _enum([
15867
+ "periodic",
15868
+ "on-motion",
15869
+ "both"
15870
+ ]).optional(),
15871
+ checkIntervalSec: number().optional()
15872
+ }), SceneMonitorSchema, {
15873
+ kind: "mutation",
15874
+ auth: "admin"
15875
+ }),
15876
+ updateScene: method(object({
15877
+ deviceId: number(),
15878
+ monitorId: string(),
15879
+ patch: object({
15880
+ label: string().optional(),
15881
+ roi: MaskRectShapeSchema.optional(),
15882
+ enabled: boolean().optional(),
15883
+ triggerMode: _enum([
15884
+ "periodic",
15885
+ "on-motion",
15886
+ "both"
15887
+ ]).optional(),
15888
+ checkIntervalSec: number().optional(),
15889
+ check: SceneCheckSchema.optional()
15890
+ })
15891
+ }), _void(), {
15892
+ kind: "mutation",
15893
+ auth: "admin"
15894
+ }),
15895
+ deleteScene: method(object({
15896
+ deviceId: number(),
15897
+ monitorId: string()
15898
+ }), _void(), {
15899
+ kind: "mutation",
15900
+ auth: "admin"
15901
+ }),
15902
+ captureReference: method(object({
15903
+ deviceId: number(),
15904
+ monitorId: string(),
15905
+ stateId: string().optional(),
15906
+ label: string().optional(),
15907
+ condition: SceneConditionSchema.optional()
15908
+ }), object({
15909
+ stateId: string(),
15910
+ referenceCount: number()
15911
+ }), {
15912
+ kind: "mutation",
15913
+ auth: "admin"
15914
+ }),
15915
+ deleteReference: method(object({
15916
+ deviceId: number(),
15917
+ monitorId: string(),
15918
+ stateId: string(),
15919
+ index: number()
15920
+ }), _void(), {
15921
+ kind: "mutation",
15922
+ auth: "admin"
15923
+ }),
15924
+ recheckNow: method(object({
15925
+ deviceId: number(),
15926
+ monitorId: string()
15927
+ }), _void(), {
15928
+ kind: "mutation",
15929
+ auth: "admin"
15930
+ })
15931
+ },
15932
+ status: {
15933
+ schema: SceneMonitorStatusSchema,
15934
+ kind: "push"
15935
+ },
15936
+ runtimeState: SceneMonitorStatusSchema
15937
+ };
15938
+ /**
15939
+ * Script-runner cap. Models HA `script.*` entities on
15940
+ * `DeviceType.Script`. A Script is a pre-recorded action sequence
15941
+ * that can be invoked imperatively — optionally with a variables
15942
+ * map when the script declares input fields.
15943
+ *
15944
+ * Variable-support is signalled by `DeviceFeature.ScriptVariables`
15945
+ * so the UI gates a "with parameters" form without a slice fetch.
15946
+ */
15947
+ var ScriptRunnerStatusSchema = object({
14049
15948
  /** Whether the script is currently executing. */
14050
15949
  isRunning: boolean(),
14051
15950
  /** Ms epoch of the last invocation start. 0 when never run. */
@@ -14059,25 +15958,68 @@ object({
14059
15958
  /** Ms epoch when the slice was last updated. */
14060
15959
  lastChangedAt: number()
14061
15960
  });
14062
- DeviceType.Script, method(object({
14063
- deviceId: number().int().nonnegative(),
14064
- /** Optional variables map — passed through to the upstream
14065
- * script. Provider rejects when the script doesn't declare
14066
- * input fields (gated by `DeviceFeature.ScriptVariables`). */
14067
- variables: record(string(), unknown()).optional()
14068
- }), _void(), {
14069
- kind: "mutation",
14070
- auth: "admin"
14071
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14072
- kind: "mutation",
14073
- auth: "admin"
14074
- });
14075
- object({
15961
+ var scriptRunnerCapability = {
15962
+ name: "script-runner",
15963
+ scope: "device",
15964
+ deviceNative: true,
15965
+ mode: "singleton",
15966
+ deviceTypes: [DeviceType.Script],
15967
+ methods: {
15968
+ run: method(object({
15969
+ deviceId: number().int().nonnegative(),
15970
+ /** Optional variables map — passed through to the upstream
15971
+ * script. Provider rejects when the script doesn't declare
15972
+ * input fields (gated by `DeviceFeature.ScriptVariables`). */
15973
+ variables: record(string(), unknown()).optional()
15974
+ }), _void(), {
15975
+ kind: "mutation",
15976
+ auth: "admin"
15977
+ }),
15978
+ /** Cancel a running script. Provider rejects when the script
15979
+ * isn't currently running. */
15980
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15981
+ kind: "mutation",
15982
+ auth: "admin"
15983
+ })
15984
+ },
15985
+ status: {
15986
+ schema: ScriptRunnerStatusSchema,
15987
+ kind: "push"
15988
+ },
15989
+ /**
15990
+ * Runtime-state slice — mirrored by the kernel. UI script tile reads
15991
+ * `isRunning` to render a spinner during execution and surfaces
15992
+ * `lastError` / `lastRunSuccess` in the recent-runs panel.
15993
+ */
15994
+ runtimeState: ScriptRunnerStatusSchema
15995
+ };
15996
+ /**
15997
+ * Smoke alarm sensor — boolean "is smoke currently detected" with
15998
+ * timestamp of the last transition. Drives Home Assistant
15999
+ * `binary_sensor` entries with `device_class: smoke`.
16000
+ *
16001
+ * Push-driven: a smoke event is critical, so the slice updates
16002
+ * immediately on the upstream signal. Auto-clearing back to false is
16003
+ * provider-controlled (some alarms latch until manually reset).
16004
+ */
16005
+ var SmokeStatusSchema = object({
14076
16006
  detected: boolean(),
14077
16007
  /** Ms epoch of the last transition. 0 if never observed. */
14078
16008
  lastChangedAt: number()
14079
16009
  });
14080
- DeviceType.Sensor;
16010
+ var smokeCapability = {
16011
+ name: "smoke",
16012
+ scope: "device",
16013
+ deviceNative: true,
16014
+ mode: "singleton",
16015
+ deviceTypes: [DeviceType.Sensor],
16016
+ methods: {},
16017
+ status: {
16018
+ schema: SmokeStatusSchema,
16019
+ kind: "push"
16020
+ },
16021
+ runtimeState: SmokeStatusSchema
16022
+ };
14081
16023
  /** One of the camera's stream profiles. */
14082
16024
  var StreamProfileSchema = _enum([
14083
16025
  "main",
@@ -14100,7 +16042,7 @@ var StreamProfileConfigSchema = object({
14100
16042
  gop: number().optional(),
14101
16043
  audio: boolean().optional()
14102
16044
  });
14103
- object({
16045
+ var StreamParamsStatusSchema = object({
14104
16046
  /** Per-profile current config. A profile absent = the camera doesn't have it. */
14105
16047
  main: StreamProfileConfigSchema.optional(),
14106
16048
  sub: StreamProfileConfigSchema.optional(),
@@ -14154,39 +16096,150 @@ var StreamProfilePatchSchema = object({
14154
16096
  gop: number().optional(),
14155
16097
  audio: boolean().optional()
14156
16098
  });
14157
- DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
14158
- deviceId: number(),
14159
- profile: StreamProfileSchema,
14160
- patch: StreamProfilePatchSchema
14161
- }), _void(), {
14162
- kind: "mutation",
14163
- auth: "admin"
14164
- }), method(object({ deviceId: number() }), unknown().nullable());
14165
- object({
16099
+ var streamParamsCapability = {
16100
+ name: "stream-params",
16101
+ scope: "device",
16102
+ deviceNative: true,
16103
+ mode: "singleton",
16104
+ deviceTypes: [DeviceType.Camera],
16105
+ deviceConfig: { ui: {
16106
+ kind: "derived-form",
16107
+ builderId: "stream-params",
16108
+ tab: "streaming"
16109
+ } },
16110
+ methods: {
16111
+ getOptions: method(object({ deviceId: number() }), StreamParamsOptionsSchema),
16112
+ setProfile: method(object({
16113
+ deviceId: number(),
16114
+ profile: StreamProfileSchema,
16115
+ patch: StreamProfilePatchSchema
16116
+ }), _void(), {
16117
+ kind: "mutation",
16118
+ auth: "admin"
16119
+ }),
16120
+ /**
16121
+ * Build the `ConfigUISchema` (admin-ui `ConfigFormBuilder` input
16122
+ * shape) for this camera's stream-encoder settings — one section per
16123
+ * profile (main / sub / ext) with the resolution / codec / framerate
16124
+ * / bitrate / bitrate-mode / encoder-profile / GOP controls the
16125
+ * firmware actually exposes.
16126
+ *
16127
+ * Driven by `getOptions` (camera-probed availability) + `getStatus`
16128
+ * (current per-profile config); each field's `default` is seeded
16129
+ * from the live config so the form renders the camera state in one
16130
+ * pass. Returns `null` when the camera exposes no configurable
16131
+ * stream property — the renderer then shows the unsupported message.
16132
+ *
16133
+ * Output is `z.unknown().nullable()` — the same convention every
16134
+ * other `ConfigUISchema`-returning cap method uses (`device-ops`,
16135
+ * `device-manager`); `ConfigUISchema` is a TS-only type with no
16136
+ * companion Zod schema, and a concrete object would collapse
16137
+ * unrelated AppRouter branches to `unknown` during codegen.
16138
+ */
16139
+ getConfigSchema: method(object({ deviceId: number() }), unknown().nullable())
16140
+ },
16141
+ status: {
16142
+ schema: StreamParamsStatusSchema,
16143
+ kind: "poll"
16144
+ },
16145
+ runtimeState: StreamParamsStatusSchema
16146
+ };
16147
+ /**
16148
+ * Generic on/off switch cap for accessory children (siren, floodlight,
16149
+ * spotlight, PIR toggle, chime silencer, autotrack enable, …). The cap
16150
+ * owns ONLY the boolean state — driver-specific settings (brightness,
16151
+ * sensitivity, schedule, duration) live on the device's own
16152
+ * `getSettingsUISchema()` surface.
16153
+ */
16154
+ var SwitchStatusSchema = object({
14166
16155
  on: boolean(),
14167
16156
  /** Ms epoch of the last state change. Useful for UI "X minutes ago". */
14168
16157
  lastChangedAt: number()
14169
16158
  });
14170
- DeviceType.Switch, DeviceType.Siren, DeviceType.Light, method(object({
14171
- deviceId: number(),
14172
- on: boolean()
14173
- }), _void(), {
14174
- kind: "mutation",
14175
- auth: "admin"
14176
- }), object({
14177
- deviceId: number(),
14178
- on: boolean(),
14179
- lastChangedAt: number()
14180
- });
14181
- object({
16159
+ var switchCapability = {
16160
+ name: "switch",
16161
+ scope: "device",
16162
+ deviceNative: true,
16163
+ mode: "singleton",
16164
+ deviceTypes: [
16165
+ DeviceType.Switch,
16166
+ DeviceType.Siren,
16167
+ DeviceType.Light
16168
+ ],
16169
+ methods: { setState: method(object({
16170
+ deviceId: number(),
16171
+ on: boolean()
16172
+ }), _void(), {
16173
+ kind: "mutation",
16174
+ auth: "admin"
16175
+ }) },
16176
+ events: { onStateChanged: { data: object({
16177
+ deviceId: number(),
16178
+ on: boolean(),
16179
+ lastChangedAt: number()
16180
+ }) } },
16181
+ status: {
16182
+ schema: SwitchStatusSchema,
16183
+ kind: "command-driven"
16184
+ },
16185
+ /**
16186
+ * Runtime-state slice — the last applied on/off state, mirrored by
16187
+ * the kernel. Read via `device.state.switch.value`; UI toggles do
16188
+ * not need to re-query the provider after a setState mutation.
16189
+ */
16190
+ runtimeState: SwitchStatusSchema,
16191
+ settings: { bindings: [{
16192
+ kind: "scalar",
16193
+ statusPath: "on",
16194
+ method: "setState",
16195
+ valueArg: "on",
16196
+ field: {
16197
+ label: "On",
16198
+ kind: "boolean"
16199
+ }
16200
+ }] }
16201
+ };
16202
+ /**
16203
+ * Tamper / case-open detection sensor. Drives Home Assistant
16204
+ * `binary_sensor` entries with `device_class: tamper`. Push-driven.
16205
+ */
16206
+ var TamperStatusSchema = object({
14182
16207
  /** True when the device's tamper switch / case-open contact is
14183
16208
  * currently triggered. */
14184
16209
  tampered: boolean(),
14185
16210
  /** Ms epoch of the last transition. 0 if never observed. */
14186
16211
  lastChangedAt: number()
14187
16212
  });
14188
- DeviceType.Sensor;
14189
- object({
16213
+ var tamperCapability = {
16214
+ name: "tamper",
16215
+ scope: "device",
16216
+ deviceNative: true,
16217
+ mode: "singleton",
16218
+ deviceTypes: [DeviceType.Sensor],
16219
+ methods: {},
16220
+ status: {
16221
+ schema: TamperStatusSchema,
16222
+ kind: "push"
16223
+ },
16224
+ runtimeState: TamperStatusSchema
16225
+ };
16226
+ /**
16227
+ * Single-metric temperature reading. Drives Home Assistant `sensor`
16228
+ * entries with `device_class: temperature` and any future native
16229
+ * thermometer.
16230
+ *
16231
+ * Unit normalisation: providers convert to Celsius before storing.
16232
+ * The slice value is always Celsius so cross-cap aggregators
16233
+ * (climate-control's `currentTemp`, energy analytics) can compose
16234
+ * without per-source unit fixups. The canonical display unit (`°C`) is
16235
+ * a descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
16236
+ * `sourceInfo`.
16237
+ *
16238
+ * Status `lastFetchedAt` lets staleness-aware consumers detect a
16239
+ * frozen feed (provider hung) distinct from a "temperature hasn't
16240
+ * changed" steady state.
16241
+ */
16242
+ var TemperatureSensorStatusSchema = object({
14190
16243
  /** Current temperature in Celsius. */
14191
16244
  celsius: number(),
14192
16245
  /** Ms epoch when the slice was last updated (push or poll). */
@@ -14202,8 +16255,29 @@ object({
14202
16255
  * auto-formatting when absent. */
14203
16256
  precision: number().int().min(0).max(10).optional()
14204
16257
  });
14205
- DeviceType.Sensor, DeviceType.Thermostat;
14206
- object({
16258
+ var temperatureSensorCapability = {
16259
+ name: "temperature-sensor",
16260
+ scope: "device",
16261
+ deviceNative: true,
16262
+ mode: "singleton",
16263
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat],
16264
+ methods: {},
16265
+ status: {
16266
+ schema: TemperatureSensorStatusSchema,
16267
+ kind: "push"
16268
+ },
16269
+ runtimeState: TemperatureSensorStatusSchema
16270
+ };
16271
+ /**
16272
+ * Firmware/software update entity. Installed on a `DeviceType.Update` device.
16273
+ * Surfaces current vs available version, whether an update is available, the
16274
+ * update state, and an install action. Read-mostly and POLL-driven: backends
16275
+ * such as the Homematic CCU do not broadcast firmware-descriptor changes, so
16276
+ * the client re-queries `getStatus` (which reads the live descriptor) rather
16277
+ * than relying on push delivery. `installUpdate` triggers the install
16278
+ * (best-effort — providers throw if the backend rejects it).
16279
+ */
16280
+ var UpdateStatusSchema = object({
14207
16281
  currentVersion: string().nullable(),
14208
16282
  availableVersion: string().nullable(),
14209
16283
  /**
@@ -14216,10 +16290,22 @@ object({
14216
16290
  state: string().nullable(),
14217
16291
  inProgress: boolean()
14218
16292
  });
14219
- DeviceType.Update, method(_void(), _void(), {
14220
- kind: "mutation",
14221
- auth: "admin"
14222
- });
16293
+ var updateCapability = {
16294
+ name: "update",
16295
+ scope: "device",
16296
+ deviceNative: true,
16297
+ mode: "singleton",
16298
+ deviceTypes: [DeviceType.Update],
16299
+ methods: { installUpdate: method(_void(), _void(), {
16300
+ kind: "mutation",
16301
+ auth: "admin"
16302
+ }) },
16303
+ status: {
16304
+ schema: UpdateStatusSchema,
16305
+ kind: "poll"
16306
+ },
16307
+ runtimeState: UpdateStatusSchema
16308
+ };
14223
16309
  /**
14224
16310
  * Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
14225
16311
  * cleaning lifecycle plus a return-to-base / locate surface and an
@@ -14271,7 +16357,7 @@ var TankStatusSchema = object({
14271
16357
  "full"
14272
16358
  ]).nullable()
14273
16359
  });
14274
- object({
16360
+ var VacuumControlStatusSchema = object({
14275
16361
  /** Lifecycle state of the vacuum. */
14276
16362
  state: VacuumStateSchema,
14277
16363
  /** 0..100 battery percentage. Null when the device has no battery
@@ -14299,29 +16385,52 @@ object({
14299
16385
  /** Ms epoch when the slice was last updated. */
14300
16386
  lastChangedAt: number()
14301
16387
  });
14302
- DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
14303
- kind: "mutation",
14304
- auth: "admin"
14305
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14306
- kind: "mutation",
14307
- auth: "admin"
14308
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14309
- kind: "mutation",
14310
- auth: "admin"
14311
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14312
- kind: "mutation",
14313
- auth: "admin"
14314
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14315
- kind: "mutation",
14316
- auth: "admin"
14317
- }), method(object({
14318
- deviceId: number().int().nonnegative(),
14319
- speed: string().min(1)
14320
- }), _void(), {
14321
- kind: "mutation",
14322
- auth: "admin"
14323
- });
14324
- object({
16388
+ var vacuumControlCapability = {
16389
+ name: "vacuum-control",
16390
+ scope: "device",
16391
+ deviceNative: true,
16392
+ mode: "singleton",
16393
+ deviceTypes: [DeviceType.Vacuum],
16394
+ methods: {
16395
+ start: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16396
+ kind: "mutation",
16397
+ auth: "admin"
16398
+ }),
16399
+ pause: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16400
+ kind: "mutation",
16401
+ auth: "admin"
16402
+ }),
16403
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16404
+ kind: "mutation",
16405
+ auth: "admin"
16406
+ }),
16407
+ returnToBase: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16408
+ kind: "mutation",
16409
+ auth: "admin"
16410
+ }),
16411
+ locate: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16412
+ kind: "mutation",
16413
+ auth: "admin"
16414
+ }),
16415
+ setFanSpeed: method(object({
16416
+ deviceId: number().int().nonnegative(),
16417
+ speed: string().min(1)
16418
+ }), _void(), {
16419
+ kind: "mutation",
16420
+ auth: "admin"
16421
+ })
16422
+ },
16423
+ status: {
16424
+ schema: VacuumControlStatusSchema,
16425
+ kind: "push"
16426
+ },
16427
+ /**
16428
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
16429
+ * slice for live state + battery + fan-speed changes.
16430
+ */
16431
+ runtimeState: VacuumControlStatusSchema
16432
+ };
16433
+ var ValveStatusSchema = object({
14325
16434
  /** Lifecycle state of the valve. */
14326
16435
  state: _enum([
14327
16436
  "open",
@@ -14336,29 +16445,80 @@ object({
14336
16445
  /** Ms epoch when the slice was last updated. */
14337
16446
  lastChangedAt: number()
14338
16447
  });
14339
- DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
14340
- kind: "mutation",
14341
- auth: "admin"
14342
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14343
- kind: "mutation",
14344
- auth: "admin"
14345
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14346
- kind: "mutation",
14347
- auth: "admin"
14348
- }), method(object({
14349
- deviceId: number().int().nonnegative(),
14350
- position: number().min(0).max(100)
14351
- }), _void(), {
14352
- kind: "mutation",
14353
- auth: "admin"
14354
- });
14355
- object({
16448
+ var valveCapability = {
16449
+ name: "valve",
16450
+ scope: "device",
16451
+ deviceNative: true,
16452
+ mode: "singleton",
16453
+ deviceTypes: [DeviceType.Valve],
16454
+ methods: {
16455
+ open: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16456
+ kind: "mutation",
16457
+ auth: "admin"
16458
+ }),
16459
+ close: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16460
+ kind: "mutation",
16461
+ auth: "admin"
16462
+ }),
16463
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16464
+ kind: "mutation",
16465
+ auth: "admin"
16466
+ }),
16467
+ setPosition: method(object({
16468
+ deviceId: number().int().nonnegative(),
16469
+ position: number().min(0).max(100)
16470
+ }), _void(), {
16471
+ kind: "mutation",
16472
+ auth: "admin"
16473
+ })
16474
+ },
16475
+ status: {
16476
+ schema: ValveStatusSchema,
16477
+ kind: "push"
16478
+ },
16479
+ /**
16480
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
16481
+ * slice for live position changes during a move.
16482
+ */
16483
+ runtimeState: ValveStatusSchema
16484
+ };
16485
+ /**
16486
+ * Vibration / shake / impact sensor. Drives Home Assistant
16487
+ * `binary_sensor` entries with `device_class: vibration`. Push-driven.
16488
+ */
16489
+ var VibrationStatusSchema = object({
14356
16490
  detected: boolean(),
14357
16491
  /** Ms epoch of the last transition. 0 if never observed. */
14358
16492
  lastChangedAt: number()
14359
16493
  });
14360
- DeviceType.Sensor;
14361
- object({
16494
+ var vibrationCapability = {
16495
+ name: "vibration",
16496
+ scope: "device",
16497
+ deviceNative: true,
16498
+ mode: "singleton",
16499
+ deviceTypes: [DeviceType.Sensor],
16500
+ methods: {},
16501
+ status: {
16502
+ schema: VibrationStatusSchema,
16503
+ kind: "push"
16504
+ },
16505
+ runtimeState: VibrationStatusSchema
16506
+ };
16507
+ /**
16508
+ * Water heater / boiler cap. Models HA `water_heater.*` entities — a
16509
+ * climate-family actuator with a target temperature, an operation-mode
16510
+ * selector (`eco` / `electric` / `gas` / `heat_pump` / `high_demand` /
16511
+ * `performance` / `off`), and an optional away-mode toggle.
16512
+ *
16513
+ * The slice carries the current + target temperature, the active
16514
+ * operation mode (HA's `state`) and its available set (HA's
16515
+ * `operation_list`), the away flag (HA `away_mode` 'on'/'off' → bool,
16516
+ * null when unsupported), plus the `min_temp` / `max_temp` bounds.
16517
+ *
16518
+ * Providers populate only what the hardware reports — temperature and
16519
+ * away fields stay null when the device has no such surface.
16520
+ */
16521
+ var WaterHeaterStatusSchema = object({
14362
16522
  /** Current measured temperature. Null when not reported. */
14363
16523
  currentTemp: number().nullable(),
14364
16524
  /** Target temperature setpoint. Null when no setpoint surface. */
@@ -14379,26 +16539,65 @@ object({
14379
16539
  /** Ms epoch when the slice was last updated. */
14380
16540
  lastChangedAt: number()
14381
16541
  });
14382
- DeviceType.WaterHeater, method(object({
14383
- deviceId: number().int().nonnegative(),
14384
- temp: number().finite()
14385
- }), _void(), {
14386
- kind: "mutation",
14387
- auth: "admin"
14388
- }), method(object({
14389
- deviceId: number().int().nonnegative(),
14390
- mode: string().min(1)
14391
- }), _void(), {
14392
- kind: "mutation",
14393
- auth: "admin"
14394
- }), method(object({
14395
- deviceId: number().int().nonnegative(),
14396
- on: boolean()
14397
- }), _void(), {
14398
- kind: "mutation",
14399
- auth: "admin"
14400
- });
14401
- object({
16542
+ var waterHeaterCapability = {
16543
+ name: "water-heater",
16544
+ scope: "device",
16545
+ deviceNative: true,
16546
+ mode: "singleton",
16547
+ deviceTypes: [DeviceType.WaterHeater],
16548
+ methods: {
16549
+ setTargetTemp: method(object({
16550
+ deviceId: number().int().nonnegative(),
16551
+ temp: number().finite()
16552
+ }), _void(), {
16553
+ kind: "mutation",
16554
+ auth: "admin"
16555
+ }),
16556
+ setOperationMode: method(object({
16557
+ deviceId: number().int().nonnegative(),
16558
+ mode: string().min(1)
16559
+ }), _void(), {
16560
+ kind: "mutation",
16561
+ auth: "admin"
16562
+ }),
16563
+ setAway: method(object({
16564
+ deviceId: number().int().nonnegative(),
16565
+ on: boolean()
16566
+ }), _void(), {
16567
+ kind: "mutation",
16568
+ auth: "admin"
16569
+ })
16570
+ },
16571
+ status: {
16572
+ schema: WaterHeaterStatusSchema,
16573
+ kind: "push"
16574
+ },
16575
+ /**
16576
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
16577
+ * slice for live temperature / mode / away changes.
16578
+ */
16579
+ runtimeState: WaterHeaterStatusSchema
16580
+ };
16581
+ /**
16582
+ * Weather provider cap. Models HA `weather.*` entities — a read-only
16583
+ * snapshot of the CURRENT conditions a weather integration reports.
16584
+ *
16585
+ * Read-only: there are no setters. The slice is populated from upstream
16586
+ * pushes (the HA weather entity's state + attributes) and rendered by
16587
+ * the UI as a sky scene + readouts.
16588
+ *
16589
+ * `condition` is the verbatim HA state string (`sunny` / `cloudy` /
16590
+ * `rainy` / `snowy` / `partlycloudy` / `pouring` / `lightning` /
16591
+ * `lightning-rainy` / `fog` / `windy` / `windy-variant` / `hail` /
16592
+ * `clear-night` / `exceptional` / …). The UI maps it to a glyph + tint;
16593
+ * unknown strings fall back to a neutral cloud.
16594
+ *
16595
+ * Every numeric reading is nullable — a given weather integration only
16596
+ * populates the metrics it actually provides.
16597
+ *
16598
+ * Forecast deferred — current conditions only for v1.
16599
+ */
16600
+ var WeatherStatusSchema = object({
14402
16601
  /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
14403
16602
  * when no condition has been reported yet. */
14404
16603
  condition: string().nullable(),
@@ -14421,7 +16620,23 @@ object({
14421
16620
  /** Ms epoch when the slice was last updated. */
14422
16621
  lastFetchedAt: number()
14423
16622
  });
14424
- DeviceType.Weather;
16623
+ var weatherCapability = {
16624
+ name: "weather",
16625
+ scope: "device",
16626
+ deviceNative: true,
16627
+ mode: "singleton",
16628
+ deviceTypes: [DeviceType.Weather],
16629
+ methods: {},
16630
+ status: {
16631
+ schema: WeatherStatusSchema,
16632
+ kind: "push"
16633
+ },
16634
+ /**
16635
+ * Runtime-state slice — mirrored by the kernel. The UI reads the
16636
+ * current conditions directly from the slice on each weather push.
16637
+ */
16638
+ runtimeState: WeatherStatusSchema
16639
+ };
14425
16640
  /**
14426
16641
  * Per-zone occupancy aggregation produced by the analytics frame
14427
16642
  * processor on every inference result. Covers the full combinatorial
@@ -14684,21 +16899,604 @@ var ZoneRuleStageEnum = _enum([
14684
16899
  "detection",
14685
16900
  "package"
14686
16901
  ]);
14687
- DeviceType.Camera, method(object({
14688
- deviceId: number(),
14689
- stage: ZoneRuleStageEnum
14690
- }), array(ZoneRuleSchema).readonly()), method(object({
14691
- deviceId: number(),
14692
- stage: ZoneRuleStageEnum,
14693
- rules: array(ZoneRuleSchema).readonly()
14694
- }), _void(), {
14695
- kind: "mutation",
14696
- auth: "admin"
14697
- }), object({
14698
- motion: array(ZoneRuleSchema).readonly(),
14699
- detection: array(ZoneRuleSchema).readonly(),
14700
- package: array(ZoneRuleSchema).readonly()
14701
- });
16902
+ /**
16903
+ * Runtime registry: cap-property-name → cap definition. `BaseDevice`'s
16904
+ * `state` getter looks up the cap definition here to construct a
16905
+ * `sliceProxy()` lazily on first access. Generated alongside the type
16906
+ * so type and runtime registry can never drift apart.
16907
+ */
16908
+ var DEVICE_LOCAL_STATE_CAPS = {
16909
+ airQualitySensor: airQualitySensorCapability,
16910
+ alarmPanel: alarmPanelCapability,
16911
+ ambientLightSensor: ambientLightSensorCapability,
16912
+ audioMetrics: audioMetricsCapability,
16913
+ automationControl: automationControlCapability,
16914
+ battery: batteryCapability,
16915
+ binary: binaryCapability,
16916
+ brightness: brightnessCapability,
16917
+ cameraStreams: cameraStreamsCapability,
16918
+ carbonMonoxide: carbonMonoxideCapability,
16919
+ climateControl: climateControlCapability,
16920
+ color: colorCapability,
16921
+ connectivity: connectivityCapability,
16922
+ consumables: consumablesCapability,
16923
+ contact: contactCapability,
16924
+ control: controlCapability,
16925
+ cover: coverCapability,
16926
+ dayNight: dayNightCapability,
16927
+ deviceDiscovery: deviceDiscoveryCapability,
16928
+ deviceStatus: deviceStatusCapability,
16929
+ doorbell: doorbellCapability,
16930
+ enumSensor: enumSensorCapability,
16931
+ eventEmitter: eventEmitterCapability,
16932
+ fanControl: fanControlCapability,
16933
+ featureProbe: featureProbeCapability,
16934
+ flood: floodCapability,
16935
+ gas: gasCapability,
16936
+ humidifier: humidifierCapability,
16937
+ humiditySensor: humiditySensorCapability,
16938
+ image: imageCapability,
16939
+ imageSettings: imageSettingsCapability,
16940
+ lawnMowerControl: lawnMowerControlCapability,
16941
+ lockControl: lockControlCapability,
16942
+ mediaPlayer: mediaPlayerCapability,
16943
+ motion: motionCapability,
16944
+ motionTrigger: motionTriggerCapability,
16945
+ motionZones: motionZonesCapability,
16946
+ nativeObjectDetection: nativeObjectDetectionCapability,
16947
+ notifier: notifierCapability,
16948
+ numericSensor: numericSensorCapability,
16949
+ petFeeder: petFeederCapability,
16950
+ powerMeter: powerMeterCapability,
16951
+ presence: presenceCapability,
16952
+ pressureSensor: pressureSensorCapability,
16953
+ privacyMask: privacyMaskCapability,
16954
+ ptzAutotrack: ptzAutotrackCapability,
16955
+ sceneMonitor: sceneMonitorCapability,
16956
+ scriptRunner: scriptRunnerCapability,
16957
+ smoke: smokeCapability,
16958
+ streamParams: streamParamsCapability,
16959
+ switch: switchCapability,
16960
+ tamper: tamperCapability,
16961
+ temperatureSensor: temperatureSensorCapability,
16962
+ update: updateCapability,
16963
+ vacuumControl: vacuumControlCapability,
16964
+ valve: valveCapability,
16965
+ vibration: vibrationCapability,
16966
+ waterHeater: waterHeaterCapability,
16967
+ weather: weatherCapability,
16968
+ zoneAnalytics: zoneAnalyticsCapability,
16969
+ zoneRules: {
16970
+ name: "zone-rules",
16971
+ scope: "device",
16972
+ mode: "singleton",
16973
+ deviceTypes: [DeviceType.Camera],
16974
+ methods: {
16975
+ /** Read the full rule list for a given stage (empty when no rules
16976
+ * are defined yet). */
16977
+ listRules: method(object({
16978
+ deviceId: number(),
16979
+ stage: ZoneRuleStageEnum
16980
+ }), array(ZoneRuleSchema).readonly()),
16981
+ /** Bulk-replace the rule list for one stage. The provider validates
16982
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
16983
+ * thresholds in range) and rejects the whole patch if any entry
16984
+ * is invalid — partial writes are a configuration footgun. */
16985
+ setRules: method(object({
16986
+ deviceId: number(),
16987
+ stage: ZoneRuleStageEnum,
16988
+ rules: array(ZoneRuleSchema).readonly()
16989
+ }), _void(), {
16990
+ kind: "mutation",
16991
+ auth: "admin"
16992
+ })
16993
+ },
16994
+ /**
16995
+ * Runtime-state slice — every stage mirrored together so consumers
16996
+ * see one reactive handle (`device.state.zoneRules.value`) instead
16997
+ * of one per stage. Bulk-replace mutations on any stage write the full
16998
+ * `{motion, detection, package}` shape, so subscribers always get the
16999
+ * complete current set. Consumers that only care about one stage
17000
+ * just read the matching property.
17001
+ *
17002
+ * `package` backs the package-drop detector — a package zone is a
17003
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
17004
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
17005
+ * The orchestrator provider writes this stage as a first-class slice
17006
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
17007
+ * package}` shape, so consumers read the current package rules directly
17008
+ * off `device.state.zoneRules.value.package`.
17009
+ */
17010
+ runtimeState: object({
17011
+ motion: array(ZoneRuleSchema).readonly(),
17012
+ detection: array(ZoneRuleSchema).readonly(),
17013
+ package: array(ZoneRuleSchema).readonly()
17014
+ })
17015
+ },
17016
+ zones: zonesCapability
17017
+ };
17018
+ var BaseDevice = class {
17019
+ id;
17020
+ stableId;
17021
+ type;
17022
+ name;
17023
+ parentDeviceId;
17024
+ role;
17025
+ /**
17026
+ * Cap-keyed runtime-state slice is the single source of truth for
17027
+ * `online`. Both getter and setter proxy to the slice — drivers can
17028
+ * write `this.online = true` ergonomically, and the cap event fires
17029
+ * automatically through the runtime-state writer. `markOnline()` is
17030
+ * kept as the explicit method form mandated by `IDevice`.
17031
+ */
17032
+ get online() {
17033
+ return this.runtimeState.getCapState("device-status")?.online ?? false;
17034
+ }
17035
+ set online(value) {
17036
+ this.markOnline(value);
17037
+ }
17038
+ /**
17039
+ * Generic per-cap runtime-state namespace. One entry per cap with
17040
+ * `runtimeState:` declared, auto-generated by codegen — see
17041
+ * `device-local-state.ts`. Drivers access via:
17042
+ *
17043
+ * `this.state.battery.sleeping = true` // patches the battery slice
17044
+ * `const pct = this.state.battery.percentage` // reads the battery slice
17045
+ * `this.state.deviceStatus.online = true` // mirrors `markOnline(true)`
17046
+ *
17047
+ * Adding a new cap with `runtimeState:` automatically extends this
17048
+ * namespace — drivers don't have to declare proxies. Reads return
17049
+ * `undefined` when the slice hasn't been seeded; writes patch via
17050
+ * `runtimeState.patchCapState` and validate against the cap's schema
17051
+ * (so partial writes need the slice to be seeded with the required
17052
+ * fields first — drivers do this on cap registration).
17053
+ *
17054
+ * For caps not exposed in `DeviceLocalState`, drivers can build their
17055
+ * own typed proxy via `this.sliceProxy(cap)`.
17056
+ */
17057
+ get state() {
17058
+ if (!this._stateProxyCache) {
17059
+ const cache = {};
17060
+ const handler = { get: (_target, key) => {
17061
+ const k = key;
17062
+ if (k in cache) return cache[k];
17063
+ const cap = DEVICE_LOCAL_STATE_CAPS[k];
17064
+ if (!cap) return void 0;
17065
+ const proxy = this.sliceProxy(cap);
17066
+ cache[k] = proxy;
17067
+ return proxy;
17068
+ } };
17069
+ this._stateProxyCache = new Proxy(cache, handler);
17070
+ }
17071
+ return this._stateProxyCache;
17072
+ }
17073
+ _stateProxyCache;
17074
+ config;
17075
+ /**
17076
+ * Per-device runtime state, cap-keyed. Always installed — slices
17077
+ * for individual caps materialise as those caps register their
17078
+ * native providers (`ctx.registerNativeCap`). The cap's own
17079
+ * `runtimeState` schema is the source of truth for the slice
17080
+ * shape; drivers don't redeclare it, they just write through.
17081
+ *
17082
+ * Read: `this.runtimeState.getCapState('battery')` →
17083
+ * `{percentage, charging, sleeping, lastUpdated}` for any
17084
+ * provider that registers `batteryCapability`.
17085
+ * Write: `this.runtimeState.setCapState('battery', { … })`.
17086
+ *
17087
+ * Cross-process consumers reach this state through the
17088
+ * `deviceState` cap router (or via cap-specific events the driver
17089
+ * emits — e.g. `battery.onStatusChanged`). The local handle is
17090
+ * accessed in-process by the driver to avoid roundtrips.
17091
+ */
17092
+ runtimeState;
17093
+ ctx;
17094
+ /**
17095
+ * Operator-organisational location label (room / area / zone).
17096
+ * Read from `ctx.deviceMeta.location`; mutated via
17097
+ * `kernel.devices.setLocation(id, value)`. Free-text — providers
17098
+ * don't interpret it; the UI groups devices by this for filters
17099
+ * like "show me all cameras in Kitchen". `null` when unset.
17100
+ */
17101
+ location;
17102
+ /**
17103
+ * Soft-disabled flag. When `true`, the device class is still
17104
+ * instantiated and visible in the UI (so the operator can flip
17105
+ * back on without re-adding) but lifecycle hooks (publishToBroker,
17106
+ * alarm-stream subscribe, …) MUST be gated by the driver to skip
17107
+ * work. The `BaseDevice` enforces this by exposing the flag here;
17108
+ * it does NOT mutate cap behaviour automatically — drivers consult
17109
+ * `this.disabled` at the top of their lifecycle methods. Read from
17110
+ * `ctx.deviceMeta.disabled`; mutated via
17111
+ * `kernel.devices.setDisabled(id, value)`.
17112
+ */
17113
+ disabled;
17114
+ /**
17115
+ * Cached materialised `SourceInfo` — either the value persisted under
17116
+ * `metadata.sourceInfo` at construction time, or a synthetic
17117
+ * `{ id: stableId, system: addonId }` for providers that haven't
17118
+ * migrated yet. Lazily populated on first `sourceInfo` read so the
17119
+ * cost of Zod-parsing the meta blob is paid once per device boot.
17120
+ * Invalidated by `updateSourceInfo()` so providers see the new value
17121
+ * back through the getter without a re-fetch from the meta surface.
17122
+ */
17123
+ _sourceInfoCache = null;
17124
+ constructor(ctx, schema, options) {
17125
+ this.ctx = ctx;
17126
+ this.id = ctx.id;
17127
+ this.stableId = ctx.stableId;
17128
+ this.type = options.type;
17129
+ if (!ctx.deviceMeta) throw new Error(`BaseDevice constructor: ctx.deviceMeta is required (id=${ctx.id} stableId=${ctx.stableId})`);
17130
+ this.name = ctx.deviceMeta.name;
17131
+ this.location = ctx.deviceMeta.location;
17132
+ this.disabled = ctx.deviceMeta.disabled;
17133
+ this.role = options.role;
17134
+ this.parentDeviceId = ctx.parentDeviceId;
17135
+ const seedData = ctx.persistedConfig ?? {};
17136
+ this.config = DeviceConfig.fromSchema(schema, (data) => ctx.persistConfig(data), seedData, ({ droppedKeys, issues }) => {
17137
+ ctx.logger.warn("Device config recovery: dropping invalid persisted fields", {
17138
+ tags: {
17139
+ deviceId: ctx.id,
17140
+ stableId: ctx.stableId
17141
+ },
17142
+ meta: {
17143
+ droppedKeys: [...droppedKeys],
17144
+ firstIssue: issues[0]?.message ?? null
17145
+ }
17146
+ });
17147
+ });
17148
+ let cachedProxy = null;
17149
+ const writer = async (capName, slice) => {
17150
+ if (!cachedProxy) cachedProxy = ctx.fetchDevice(ctx.id);
17151
+ await (await cachedProxy).deviceState.setCapSlice({
17152
+ capName,
17153
+ slice
17154
+ });
17155
+ };
17156
+ const initial = ctx.initialRuntimeState ?? {};
17157
+ this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer);
17158
+ ctx.bindRuntimeState?.(this.runtimeState);
17159
+ ctx.registerNativeCap?.(deviceStatusCapability, {});
17160
+ const seed = {
17161
+ online: true,
17162
+ lastChangedAt: Date.now()
17163
+ };
17164
+ this.runtimeState.setCapState("device-status", seed);
17165
+ ctx.registerNativeCap?.(featureProbeCapability, {});
17166
+ this.runtimeState.setCapState("feature-probe", {
17167
+ flags: {},
17168
+ deviceType: null,
17169
+ model: null,
17170
+ channelCount: null,
17171
+ lastProbedAt: 0,
17172
+ lastFetchedAt: 0
17173
+ });
17174
+ }
17175
+ deviceActions = /* @__PURE__ */ new Map();
17176
+ /** Declare a device custom action + its typed handler. Idempotent per name. */
17177
+ registerDeviceAction(name, spec, handler) {
17178
+ this.deviceActions.set(name, {
17179
+ spec,
17180
+ handler
17181
+ });
17182
+ }
17183
+ /** Invoke a registered device action. Validates input against the spec. */
17184
+ async runDeviceAction(action, input) {
17185
+ const entry = this.deviceActions.get(action);
17186
+ if (!entry) throw new Error(`unknown device action "${action}" on device ${this.id}`);
17187
+ const parsed = entry.spec.input.parse(input);
17188
+ return entry.handler(parsed);
17189
+ }
17190
+ async removeDevice() {}
17191
+ /**
17192
+ * Set the device's online flag. Called by `BaseDeviceProvider` after
17193
+ * aggregating per-profile stream-broker health, or directly by drivers
17194
+ * that have provider-side liveness signals (e.g. ONVIF heartbeats,
17195
+ * Reolink Baichuan firmware push events). Mirrors the new value into
17196
+ * the `device-status` runtime-state slice so cross-process consumers
17197
+ * pick it up via the standard cap-state channel. Subclasses can
17198
+ * override to gate side effects on the transition.
17199
+ */
17200
+ markOnline(online) {
17201
+ if (this.online === online) return;
17202
+ const next = {
17203
+ online,
17204
+ lastChangedAt: Date.now()
17205
+ };
17206
+ this.runtimeState.setCapState("device-status", next);
17207
+ }
17208
+ /**
17209
+ * Upstream-system identity + rendering envelope for this device. See
17210
+ * `SourceInfo` for the field contract. Always returns a valid object:
17211
+ * if the persisted `metadata.sourceInfo` blob is absent or fails Zod
17212
+ * validation, falls back to a synthetic `{ id: stableId, system: addonId }`
17213
+ * so providers that haven't migrated keep working without code changes.
17214
+ *
17215
+ * The value is cached after the first read. `updateSourceInfo()`
17216
+ * invalidates the cache so subsequent reads see the new patch. The
17217
+ * returned object is frozen to prevent accidental in-place mutation —
17218
+ * use `updateSourceInfo({ patch })` to change fields.
17219
+ */
17220
+ get sourceInfo() {
17221
+ if (this._sourceInfoCache) return this._sourceInfoCache;
17222
+ const resolved = extractSourceInfoFromMetadata(this.ctx.deviceMeta.metadata) ?? synthesizeSourceInfo({
17223
+ stableId: this.stableId,
17224
+ addonId: this.ctx.deviceMeta.addonId
17225
+ });
17226
+ this._sourceInfoCache = Object.freeze({ ...resolved });
17227
+ return this._sourceInfoCache;
17228
+ }
17229
+ /**
17230
+ * Convenience accessor for the upstream dispatch key. Equivalent to
17231
+ * `this.sourceInfo.id` — providers use this to keep a
17232
+ * `Map<sourceId, IDevice>` for routing inbound push events.
17233
+ */
17234
+ get sourceId() {
17235
+ return this.sourceInfo.id;
17236
+ }
17237
+ /**
17238
+ * Patch the device's `SourceInfo`. Shallow-merges `patch` over the
17239
+ * current value, persists the merged result under
17240
+ * `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
17241
+ * emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
17242
+ *
17243
+ * Safe to call from anywhere in the device's lifetime — the call is
17244
+ * idempotent for `undefined` patch values (ignored) and best-effort
17245
+ * for persistence (a transient device-manager error doesn't unwind
17246
+ * the local cache update, so subsequent reads still see the patch).
17247
+ *
17248
+ * Drivers populate this on adoption + on every metadata change push
17249
+ * from the upstream source. Subscribers (UI, export adapters) react
17250
+ * via the `DeviceSourceInfoChanged` event without polling.
17251
+ */
17252
+ async updateSourceInfo(patch) {
17253
+ const next = mergeSourceInfo(this.sourceInfo, patch);
17254
+ this._sourceInfoCache = Object.freeze({ ...next });
17255
+ const action = this.ctx.api?.deviceManager?.setMetadata;
17256
+ if (action) try {
17257
+ await action.mutate({
17258
+ deviceId: this.id,
17259
+ patch: { [SOURCE_INFO_METADATA_KEY]: next }
17260
+ });
17261
+ } catch {}
17262
+ this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
17263
+ type: "device",
17264
+ id: this.stableId
17265
+ }, {
17266
+ deviceId: this.id,
17267
+ sourceInfo: next
17268
+ }));
17269
+ }
17270
+ /**
17271
+ * Re-publish the device's current `features` array to the persisted
17272
+ * meta blob. Drivers call this after a probe finishes when the live
17273
+ * `features` getter has gained new flags (e.g. `hasIntercom` flips
17274
+ * to true → `DeviceFeature.TwoWayAudio` joins the list).
17275
+ *
17276
+ * Without this, only the construction-time snapshot is written —
17277
+ * `deviceManager.registerDevice` is invoked once per boot, so probe-
17278
+ * driven additions don't reach the persisted index until the next
17279
+ * server restart, and `getDevice` / `listAll` keep returning the
17280
+ * stale list for forked-worker devices (whose live IDevice instance
17281
+ * is invisible to the hub registry).
17282
+ *
17283
+ * Idempotent: re-calling with the same features just no-ops on the
17284
+ * persisted meta. Best-effort: lookup or write failures are logged
17285
+ * at debug and swallowed — the live `device.features` getter is
17286
+ * still authoritative within this process, so callers never block
17287
+ * device boot on a meta refresh.
17288
+ */
17289
+ async refreshFeatures() {
17290
+ const action = this.ctx.api?.deviceManager?.registerDevice;
17291
+ if (!action) return;
17292
+ try {
17293
+ await action.mutate({
17294
+ addonId: this.ctx.deviceMeta.addonId,
17295
+ stableId: this.stableId,
17296
+ id: this.id,
17297
+ type: this.type,
17298
+ name: this.name,
17299
+ parentDeviceId: this.parentDeviceId,
17300
+ features: [...this.features],
17301
+ config: {}
17302
+ });
17303
+ } catch (err) {}
17304
+ }
17305
+ /**
17306
+ * Typed read-through to a cap-keyed runtime-state slice. Drivers
17307
+ * call `this.getCapSlice(batteryCapability)` and the return type
17308
+ * is inferred from the cap's `runtimeState` Zod schema — no string
17309
+ * key, no manual generic. Returns `null` when the slice hasn't
17310
+ * been written yet (e.g. driver hasn't seeded battery yet).
17311
+ */
17312
+ getCapSlice(cap) {
17313
+ return this.runtimeState.getCapState(cap.name) ?? null;
17314
+ }
17315
+ /**
17316
+ * Typed writer to a cap-keyed runtime-state slice. Routes through
17317
+ * the runtime-state writer (validate → persist → emit cap event).
17318
+ * Equivalent to `this.runtimeState.setCapState(cap.name, value)`
17319
+ * but with the cap's `runtimeState` schema enforcing the value
17320
+ * shape at compile time. Mirrors the symmetry of
17321
+ * `getCapSlice` / `setCapSlice` for cross-cap consistency.
17322
+ */
17323
+ setCapSlice(cap, value) {
17324
+ this.runtimeState.setCapState(cap.name, value);
17325
+ }
17326
+ /**
17327
+ * Field-level read/write proxy over a cap's runtime-state slice.
17328
+ * Drivers that want ergonomic per-field access declare:
17329
+ *
17330
+ * ```ts
17331
+ * protected battery = this.sliceProxy(batteryCapability)
17332
+ * // …
17333
+ * this.battery.sleeping = true // patches the slice
17334
+ * const charging = this.battery.charging // reads the slice
17335
+ * ```
17336
+ *
17337
+ * Reads return `undefined` when the slice hasn't been seeded yet
17338
+ * (cap not registered, or seeded but the field is absent). Writes
17339
+ * route through `runtimeState.patchCapState` so the cap's `runtimeState`
17340
+ * schema validates the merged result and the cap event fires.
17341
+ *
17342
+ * Pattern is generic — same shape works for `battery`, `device-status`,
17343
+ * `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
17344
+ * declare one proxy per cap they read/write directly.
17345
+ */
17346
+ sliceProxy(cap) {
17347
+ return new Proxy({}, {
17348
+ get: (_, key) => {
17349
+ return this.runtimeState.getCapState(cap.name)?.[key];
17350
+ },
17351
+ set: (_, key, value) => {
17352
+ this.runtimeState.patchCapState(cap.name, { [key]: value });
17353
+ return true;
17354
+ },
17355
+ has: (_, key) => {
17356
+ const slice = this.runtimeState.getCapState(cap.name);
17357
+ return slice ? key in slice : false;
17358
+ },
17359
+ ownKeys: () => {
17360
+ const slice = this.runtimeState.getCapState(cap.name);
17361
+ return slice ? Object.keys(slice) : [];
17362
+ },
17363
+ getOwnPropertyDescriptor: (_, key) => {
17364
+ const slice = this.runtimeState.getCapState(cap.name);
17365
+ if (!slice || !(key in slice)) return void 0;
17366
+ return {
17367
+ configurable: true,
17368
+ enumerable: true,
17369
+ value: slice[key]
17370
+ };
17371
+ }
17372
+ });
17373
+ }
17374
+ /**
17375
+ * Default empty settings UI. Drivers override this to expose an
17376
+ * editable form in the device-details page. Returning an empty sections
17377
+ * array signals "nothing to contribute" — the aggregator drops the
17378
+ * contribution entirely rather than rendering a blank panel.
17379
+ */
17380
+ getSettingsUISchema() {
17381
+ return { sections: [] };
17382
+ }
17383
+ /**
17384
+ * Default write path: forward the flat patch directly to storage.
17385
+ * Drivers that project a UI shape different from storage (e.g. `RtspCamera`
17386
+ * exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
17387
+ * to reshape before `config.setAll`.
17388
+ */
17389
+ async applySettingsPatch(patch) {
17390
+ await this.config.setAll(patch);
17391
+ }
17392
+ /**
17393
+ * Phase 3 — populate device-scoped state needed by downstream phases
17394
+ * (accessory reconciliation, public `features` array, optional cap
17395
+ * registration). Called ONCE per construction, after register but
17396
+ * before `getAccessoryChildren()`.
17397
+ *
17398
+ * Drivers write the `feature-probe` runtime-state slice via
17399
+ * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
17400
+ * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
17401
+ * `hasSupplementalLight/hasAlarmIo`, etc).
17402
+ *
17403
+ * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
17404
+ * the kernel treats it as ready immediately. A device that derives its shape
17405
+ * from a spec (a container, or an accessory sensor) rather than from a
17406
+ * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
17407
+ * it would look perpetually un-probed — logging "Initial probe did not
17408
+ * complete" on every boot and spinning a pointless retry chain. Drivers that
17409
+ * DO probe override this and write their own `feature-probe` slice (including
17410
+ * `lastProbedAt`) once their probe actually succeeds.
17411
+ */
17412
+ async onProbe() {
17413
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
17414
+ flags: {},
17415
+ deviceType: null,
17416
+ model: null,
17417
+ channelCount: null,
17418
+ lastProbedAt: 0,
17419
+ lastFetchedAt: 0
17420
+ };
17421
+ this.runtimeState.setCapState("feature-probe", {
17422
+ ...base,
17423
+ lastProbedAt: Date.now()
17424
+ });
17425
+ }
17426
+ /**
17427
+ * Phase 5 — fired after the device + its accessories are registered.
17428
+ * Drivers publish streams to the broker, kick off background tasks,
17429
+ * or subscribe to lib events that need a fully-registered device id.
17430
+ *
17431
+ * Default: no-op.
17432
+ *
17433
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
17434
+ * pass). The new name reflects the post-probe, post-accessory contract.
17435
+ */
17436
+ async onActivate() {}
17437
+ /**
17438
+ * Re-run the probe + reconcile accessories + refresh features meta.
17439
+ * Drivers call this when device-side state changes (battery cam wakes,
17440
+ * firmware update, manual operator trigger).
17441
+ *
17442
+ * The kernel injects `_kernelReprobe` on registration so this method
17443
+ * delegates to the same orchestrator that runs the boot-time phase
17444
+ * 3 + 4 sequence. Drivers should NOT override this — they override
17445
+ * `onProbe()` instead.
17446
+ */
17447
+ async reprobe() {
17448
+ if (this._kernelReprobe) await this._kernelReprobe();
17449
+ else await this.onProbe();
17450
+ }
17451
+ /**
17452
+ * Kernel-injected callback that runs the full post-probe orchestration
17453
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
17454
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
17455
+ * directly — call `reprobe()` instead.
17456
+ */
17457
+ _kernelReprobe;
17458
+ /**
17459
+ * Declare accessory child devices the kernel should auto-spawn
17460
+ * after `onProbe()` resolves. Each spec fully describes one child
17461
+ * — stableId suffix (deterministic per kind for restore-safety),
17462
+ * meta (type / name / location), config (initial blob the child
17463
+ * self-hydrates), and a factory that constructs the concrete
17464
+ * class with whatever closure-captured refs it needs (typically
17465
+ * `this` for the parent reference).
17466
+ *
17467
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
17468
+ * (skipped on restore when the row already exists),
17469
+ * persistInitialMeta, createContext, factory invocation, register,
17470
+ * and recursive lifecycle (probe + accessories + activate).
17471
+ *
17472
+ * Implementations should derive children from
17473
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
17474
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
17475
+ * with a typed cast.
17476
+ *
17477
+ * Default: no children.
17478
+ */
17479
+ getAccessoryChildren() {
17480
+ return [];
17481
+ }
17482
+ /**
17483
+ * Read the current feature-probe flag bag with a typed cast. Helper
17484
+ * for `getAccessoryChildren()` and `features` getters that derive
17485
+ * outputs from the probe results.
17486
+ */
17487
+ getProbeFlags() {
17488
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
17489
+ }
17490
+ /**
17491
+ * Returns true once `onProbe` has completed at least once
17492
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
17493
+ * to avoid spawning stale accessories on a fresh device whose probe
17494
+ * hasn't landed yet.
17495
+ */
17496
+ hasProbed() {
17497
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
17498
+ }
17499
+ };
14702
17500
  var ProviderStatusSchema = object({
14703
17501
  connected: boolean(),
14704
17502
  deviceCount: number(),
@@ -15782,6 +18580,10 @@ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, Broke
15782
18580
  auth: "admin"
15783
18581
  }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
15784
18582
  DeviceType.Camera;
18583
+ /** kebab-case cap name → camelCase router-map key. */
18584
+ function kebabToCamel(s) {
18585
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
18586
+ }
15785
18587
  /**
15786
18588
  * Identity — preserves literal types for downstream inference.
15787
18589
  *
@@ -27841,6 +30643,12 @@ Object.defineProperty(exports, "BaseAddon", {
27841
30643
  return BaseAddon;
27842
30644
  }
27843
30645
  });
30646
+ Object.defineProperty(exports, "BaseDevice", {
30647
+ enumerable: true,
30648
+ get: function() {
30649
+ return BaseDevice;
30650
+ }
30651
+ });
27844
30652
  Object.defineProperty(exports, "DEFAULT_EVENT_COLOR", {
27845
30653
  enumerable: true,
27846
30654
  get: function() {
@@ -27973,6 +30781,12 @@ Object.defineProperty(exports, "addonWidgetsSourceCapability", {
27973
30781
  return addonWidgetsSourceCapability;
27974
30782
  }
27975
30783
  });
30784
+ Object.defineProperty(exports, "alarmPanelCapability", {
30785
+ enumerable: true,
30786
+ get: function() {
30787
+ return alarmPanelCapability;
30788
+ }
30789
+ });
27976
30790
  Object.defineProperty(exports, "array", {
27977
30791
  enumerable: true,
27978
30792
  get: function() {
@@ -28051,6 +30865,18 @@ Object.defineProperty(exports, "hydrateSchema", {
28051
30865
  return hydrateSchema;
28052
30866
  }
28053
30867
  });
30868
+ Object.defineProperty(exports, "isDeviceScopedCap", {
30869
+ enumerable: true,
30870
+ get: function() {
30871
+ return isDeviceScopedCap;
30872
+ }
30873
+ });
30874
+ Object.defineProperty(exports, "kebabToCamel", {
30875
+ enumerable: true,
30876
+ get: function() {
30877
+ return kebabToCamel;
30878
+ }
30879
+ });
28054
30880
  Object.defineProperty(exports, "literal", {
28055
30881
  enumerable: true,
28056
30882
  get: function() {
@@ -28099,6 +30925,12 @@ Object.defineProperty(exports, "readDeviceStateFrom", {
28099
30925
  return readDeviceStateFrom;
28100
30926
  }
28101
30927
  });
30928
+ Object.defineProperty(exports, "record", {
30929
+ enumerable: true,
30930
+ get: function() {
30931
+ return record;
30932
+ }
30933
+ });
28102
30934
  Object.defineProperty(exports, "string", {
28103
30935
  enumerable: true,
28104
30936
  get: function() {
@@ -28111,6 +30943,12 @@ Object.defineProperty(exports, "subKindsOf", {
28111
30943
  return subKindsOf;
28112
30944
  }
28113
30945
  });
30946
+ Object.defineProperty(exports, "unknown", {
30947
+ enumerable: true,
30948
+ get: function() {
30949
+ return unknown;
30950
+ }
30951
+ });
28114
30952
  Object.defineProperty(exports, "videoclipsCapability", {
28115
30953
  enumerable: true,
28116
30954
  get: function() {