@camstack/addon-post-analysis 1.2.25 → 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.
@@ -6513,6 +6513,109 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6513
6513
  function nodePin(nodeId) {
6514
6514
  return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6515
6515
  }
6516
+ /**
6517
+ * AUTO-GENERATED by scripts/generate-device-scoped-caps.ts — DO NOT EDIT.
6518
+ *
6519
+ * Every `scope: 'device'` capability name, as plain data — so a forked runner
6520
+ * can answer "may a rule actuate this?" without importing the schema barrel
6521
+ * (~144MB RSS per runner, D28).
6522
+ *
6523
+ * Coverage: 80 device-scoped capabilities.
6524
+ */
6525
+ var DEVICE_SCOPED_CAPS = new Set([
6526
+ "accessories",
6527
+ "air-quality-sensor",
6528
+ "alarm-panel",
6529
+ "ambient-light-sensor",
6530
+ "audio-analysis",
6531
+ "audio-metrics",
6532
+ "automation-control",
6533
+ "battery",
6534
+ "binary",
6535
+ "brightness",
6536
+ "button",
6537
+ "camera-credentials",
6538
+ "camera-pipeline-config",
6539
+ "camera-streams",
6540
+ "carbon-monoxide",
6541
+ "climate-control",
6542
+ "color",
6543
+ "connectivity",
6544
+ "consumables",
6545
+ "contact",
6546
+ "control",
6547
+ "cover",
6548
+ "day-night",
6549
+ "detection-pipeline",
6550
+ "device-discovery",
6551
+ "device-ops",
6552
+ "device-status",
6553
+ "doorbell",
6554
+ "enum-sensor",
6555
+ "event-emitter",
6556
+ "events",
6557
+ "fan-control",
6558
+ "feature-probe",
6559
+ "flood",
6560
+ "gas",
6561
+ "humidifier",
6562
+ "humidity-sensor",
6563
+ "image",
6564
+ "image-settings",
6565
+ "intercom",
6566
+ "lawn-mower-control",
6567
+ "lock-control",
6568
+ "media-player",
6569
+ "motion",
6570
+ "motion-detection",
6571
+ "motion-trigger",
6572
+ "motion-zones",
6573
+ "native-object-detection",
6574
+ "notifier",
6575
+ "numeric-sensor",
6576
+ "osd",
6577
+ "pet-feeder",
6578
+ "pipeline-analytics",
6579
+ "power-meter",
6580
+ "presence",
6581
+ "pressure-sensor",
6582
+ "privacy-mask",
6583
+ "ptz",
6584
+ "ptz-autotrack",
6585
+ "reboot",
6586
+ "scene-monitor",
6587
+ "script-runner",
6588
+ "smoke",
6589
+ "snapshot",
6590
+ "stream-catalog",
6591
+ "stream-params",
6592
+ "switch",
6593
+ "tamper",
6594
+ "temperature-sensor",
6595
+ "update",
6596
+ "vacuum-control",
6597
+ "valve",
6598
+ "vibration",
6599
+ "videoclips",
6600
+ "water-heater",
6601
+ "weather",
6602
+ "webrtc-session",
6603
+ "zone-analytics",
6604
+ "zone-rules",
6605
+ "zones"
6606
+ ]);
6607
+ /**
6608
+ * True when `capName` is a device capability.
6609
+ *
6610
+ * This is the ONLY boundary on what a notification rule may actuate. A rule can
6611
+ * be authored by a non-admin and the runner executes with the addon's
6612
+ * privileges, so an unbounded action would be an arbitrary RPC channel with a
6613
+ * privilege escalation attached. Device scope excludes the system caps
6614
+ * (`device-manager.removeDevice` and friends) by construction.
6615
+ */
6616
+ function isDeviceScopedCap(capName) {
6617
+ return DEVICE_SCOPED_CAPS.has(capName);
6618
+ }
6516
6619
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
6517
6620
  DeviceType["Camera"] = "camera";
6518
6621
  DeviceType["Hub"] = "hub";
@@ -6898,6 +7001,10 @@ function systemMethod(input, output, options) {
6898
7001
  systemOnly: true
6899
7002
  };
6900
7003
  }
7004
+ /** Shorthand to define an event schema */
7005
+ function event(data) {
7006
+ return { data };
7007
+ }
6901
7008
  var StaticDirOutputSchema$1 = object({ staticDir: string() });
6902
7009
  var VersionOutputSchema$1 = object({ version: string() });
6903
7010
  method(_void(), StaticDirOutputSchema$1), method(_void(), VersionOutputSchema$1);
@@ -8392,6 +8499,60 @@ function buildNcTaxonomy() {
8392
8499
  /** The frozen NC taxonomy, derived once from the taxonomy dictionary. */
8393
8500
  var NC_TAXONOMY = Object.freeze(buildNcTaxonomy());
8394
8501
  /**
8502
+ * Most specific first. Extending this list is how a new device kind becomes
8503
+ * gateable; nothing else needs to change.
8504
+ */
8505
+ var DEVICE_STATE_READERS = [
8506
+ {
8507
+ cap: "alarm-panel",
8508
+ field: "state"
8509
+ },
8510
+ {
8511
+ cap: "cover",
8512
+ field: "state"
8513
+ },
8514
+ {
8515
+ cap: "presence",
8516
+ field: "state"
8517
+ },
8518
+ {
8519
+ cap: "lock",
8520
+ field: "locked",
8521
+ booleanWords: ["locked", "unlocked"]
8522
+ },
8523
+ {
8524
+ cap: "contact",
8525
+ field: "entryOpen",
8526
+ booleanWords: ["open", "closed"]
8527
+ },
8528
+ {
8529
+ cap: "switch",
8530
+ field: "on",
8531
+ booleanWords: ["on", "off"]
8532
+ },
8533
+ {
8534
+ cap: "binary",
8535
+ field: "on",
8536
+ booleanWords: ["on", "off"]
8537
+ }
8538
+ ];
8539
+ /**
8540
+ * Collapse a device's full runtime state to the one string a rule compares
8541
+ * against, or `undefined` when nothing in the table applies.
8542
+ *
8543
+ * `undefined` is the safe answer everywhere: the gate treats it as "does not
8544
+ * match", so a device whose kind we cannot read simply never arms a rule.
8545
+ */
8546
+ function readDeviceStateFrom(runtimeState) {
8547
+ for (const reader of DEVICE_STATE_READERS) {
8548
+ const slice = runtimeState[reader.cap];
8549
+ if (slice === null || typeof slice !== "object") continue;
8550
+ const value = slice[reader.field];
8551
+ if (typeof value === "string" && value.length > 0) return value;
8552
+ if (typeof value === "boolean" && reader.booleanWords !== void 0) return value ? reader.booleanWords[0] : reader.booleanWords[1];
8553
+ }
8554
+ }
8555
+ /**
8395
8556
  * Error types for the safe expression engine. Two distinct classes so callers
8396
8557
  * can tell a compile-time (grammar) failure from a runtime (evaluation)
8397
8558
  * failure — both are non-fatal to the host: read paths degrade to "skip link".
@@ -9026,6 +9187,249 @@ var AccessoryKind = {
9026
9187
  };
9027
9188
  AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
9028
9189
  DeviceFeature.BatteryOperated;
9190
+ var DeviceConfig = class DeviceConfig {
9191
+ schema;
9192
+ data;
9193
+ persistFn;
9194
+ constructor(schema, data, persist) {
9195
+ this.schema = schema;
9196
+ this.data = data;
9197
+ this.persistFn = persist;
9198
+ }
9199
+ /**
9200
+ * Build a `DeviceConfig` from a persisted blob, with automatic
9201
+ * recovery from schema-validation failures. Boot must never be
9202
+ * blocked by stale persisted values: if Zod rejects the blob,
9203
+ * we drop every offending top-level field, retry, and persist
9204
+ * the cleaned blob so the bad value is healed in the DB on next
9205
+ * write. The most common trigger is a tightened range constraint
9206
+ * (e.g. `max(100) → max(50)`) on a field that already has an
9207
+ * out-of-range value persisted from the previous schema. Without
9208
+ * this safety net, the device would fail to instantiate and end
9209
+ * up with no caps registered — exactly the failure mode that
9210
+ * stranded device 15 when `motionSensitivity: 90` no longer fit
9211
+ * the new `1..50` schema.
9212
+ *
9213
+ * Recovery rules:
9214
+ * 1. Try `safeParse(initialData)`. If it succeeds, done.
9215
+ * 2. On failure, walk `error.issues`, collect the top-level path
9216
+ * of each issue, and drop those keys from `initialData`.
9217
+ * 3. Re-run `safeParse`. If the cleaned blob now passes (Zod
9218
+ * fills the missing keys with schema defaults / undefined for
9219
+ * `.optional()`), persist it via `persist()` so the bad
9220
+ * values disappear from the DB, and return the device.
9221
+ * 4. If the cleaned blob STILL fails (very rare — would require
9222
+ * a non-recoverable required field), fall back to
9223
+ * `schema.parse({})` so the device still boots with pure
9224
+ * schema defaults. Persist nothing in that path so the next
9225
+ * successful `setAll` still writes a coherent blob.
9226
+ */
9227
+ static fromSchema(schema, persist, initialData = {}, onRecover) {
9228
+ const first = schema.safeParse(initialData);
9229
+ if (first.success) return new DeviceConfig(schema, first.data, persist);
9230
+ const droppedKeys = /* @__PURE__ */ new Set();
9231
+ for (const issue of first.error.issues) {
9232
+ const top = issue.path[0];
9233
+ if (typeof top === "string") droppedKeys.add(top);
9234
+ }
9235
+ const cleaned = { ...initialData };
9236
+ for (const k of droppedKeys) delete cleaned[k];
9237
+ const second = schema.safeParse(cleaned);
9238
+ onRecover?.({
9239
+ droppedKeys: [...droppedKeys],
9240
+ issues: first.error.issues
9241
+ });
9242
+ if (second.success) {
9243
+ persist(second.data).catch(() => {});
9244
+ return new DeviceConfig(schema, second.data, persist);
9245
+ }
9246
+ return new DeviceConfig(schema, schema.parse({}), persist);
9247
+ }
9248
+ get values() {
9249
+ return this.data;
9250
+ }
9251
+ get(key) {
9252
+ return this.data[key];
9253
+ }
9254
+ async set(key, value) {
9255
+ const next = this.schema.parse({
9256
+ ...this.data,
9257
+ [key]: value
9258
+ });
9259
+ this.data = next;
9260
+ await this.persistFn(this.data);
9261
+ }
9262
+ /**
9263
+ * Merge an untyped patch onto the current config and persist. Accepts
9264
+ * `Record<string, unknown>` because the patch typically comes from the
9265
+ * UI form layer (a `ConfigField.key → value` map) where the caller
9266
+ * doesn't hold the Zod schema's static type. Runtime validation is
9267
+ * authoritative: `this.schema.parse` rejects unknown keys or invalid
9268
+ * shapes before touching storage.
9269
+ */
9270
+ async setAll(partial) {
9271
+ const next = this.schema.parse({
9272
+ ...this.data,
9273
+ ...partial
9274
+ });
9275
+ this.data = next;
9276
+ await this.persistFn(this.data);
9277
+ }
9278
+ async deleteKey(key) {
9279
+ const { [key]: _, ...rest } = this.data;
9280
+ const next = this.schema.parse(rest);
9281
+ this.data = next;
9282
+ await this.persistFn(this.data);
9283
+ }
9284
+ entries() {
9285
+ const shape = this.schema.shape;
9286
+ return Object.entries(shape).map(([key, fieldSchema]) => ({
9287
+ key,
9288
+ schema: fieldSchema,
9289
+ value: this.data[key],
9290
+ description: fieldSchema.description
9291
+ }));
9292
+ }
9293
+ };
9294
+ /**
9295
+ * Concrete implementation. Routes every successful write through
9296
+ * `writer(capName, slice)` — the kernel hooks this up to
9297
+ * `device-state.setCapSlice`, the canonical cross-layer write
9298
+ * entrypoint, which handles disk persistence (debounced on the hub)
9299
+ * and mirror updates.
9300
+ *
9301
+ * Schema validation runs in-process before the writer is called —
9302
+ * the round-trip should never carry an invalid slice. `flush()`
9303
+ * awaits any in-flight writer promises so shutdown is lossless.
9304
+ *
9305
+ * `initial` is the persisted blob loaded at boot. Slices for caps
9306
+ * whose schema hasn't been installed yet are kept in-memory verbatim
9307
+ * and validated when the cap registers later.
9308
+ */
9309
+ var DeviceRuntimeState = class DeviceRuntimeState {
9310
+ writer;
9311
+ /** In-flight writer promises tracked so `flush()` can await them. */
9312
+ pendingWrites = /* @__PURE__ */ new Set();
9313
+ /** Per-cap committed slice — after schema validation when known. */
9314
+ slices;
9315
+ /** Per-cap registered schema (set by `installCapSchema`). */
9316
+ schemas = /* @__PURE__ */ new Map();
9317
+ listeners = /* @__PURE__ */ new Set();
9318
+ capListeners = /* @__PURE__ */ new Map();
9319
+ constructor(initial, writer) {
9320
+ this.writer = writer;
9321
+ this.slices = /* @__PURE__ */ new Map();
9322
+ for (const [k, v] of Object.entries(initial)) if (v && typeof v === "object" && !Array.isArray(v)) this.slices.set(k, { ...v });
9323
+ }
9324
+ static fromInitial(initial, writer) {
9325
+ return new DeviceRuntimeState(initial, writer);
9326
+ }
9327
+ installCapSchema(capName, schema) {
9328
+ const existing = this.schemas.get(capName);
9329
+ if (existing) {
9330
+ if (existing !== schema) throw new Error(`[DeviceRuntimeState] capability "${capName}" registered a different runtime-state schema; each cap must declare ONE shape across every provider`);
9331
+ return;
9332
+ }
9333
+ this.schemas.set(capName, schema);
9334
+ const stored = this.slices.get(capName);
9335
+ if (stored) {
9336
+ const result = schema.safeParse(stored);
9337
+ if (result.success) this.slices.set(capName, result.data);
9338
+ else this.slices.delete(capName);
9339
+ }
9340
+ }
9341
+ getCapState(capName) {
9342
+ const slice = this.slices.get(capName);
9343
+ if (!slice) return void 0;
9344
+ return Object.freeze({ ...slice });
9345
+ }
9346
+ getCapField(capName, key) {
9347
+ return this.slices.get(capName)?.[key];
9348
+ }
9349
+ setCapState(capName, value) {
9350
+ this.applyCapWrite(capName, value, false);
9351
+ }
9352
+ patchCapState(capName, partial) {
9353
+ this.applyCapWrite(capName, partial, true);
9354
+ }
9355
+ /**
9356
+ * Internal worker. `merge` controls whether `value` replaces or
9357
+ * shallow-merges into the existing slice. Schema validation runs
9358
+ * on the FINAL composed object regardless.
9359
+ */
9360
+ applyCapWrite(capName, value, merge) {
9361
+ const schema = this.schemas.get(capName);
9362
+ if (!schema) throw new Error(`[DeviceRuntimeState] no schema registered for cap "${capName}" — did the device register it via ctx.registerNativeCap before writing?`);
9363
+ const current = this.slices.get(capName) ?? {};
9364
+ const next = merge ? {
9365
+ ...current,
9366
+ ...value
9367
+ } : { ...value };
9368
+ const parsed = schema.parse(next);
9369
+ if (shallowEqual(current, parsed)) return;
9370
+ this.slices.set(capName, parsed);
9371
+ this.fireListeners([capName]);
9372
+ const writePromise = this.writer(capName, { ...parsed }).catch(() => {});
9373
+ this.pendingWrites.add(writePromise);
9374
+ writePromise.finally(() => {
9375
+ this.pendingWrites.delete(writePromise);
9376
+ });
9377
+ }
9378
+ fireListeners(changed) {
9379
+ const snap = this.snapshot();
9380
+ for (const cb of this.listeners) try {
9381
+ cb(changed, snap);
9382
+ } catch {}
9383
+ for (const capName of changed) {
9384
+ const subs = this.capListeners.get(capName);
9385
+ if (!subs) continue;
9386
+ const slice = this.getCapState(capName);
9387
+ for (const cb of subs) try {
9388
+ cb(slice);
9389
+ } catch {}
9390
+ }
9391
+ }
9392
+ subscribe(cb) {
9393
+ this.listeners.add(cb);
9394
+ return () => {
9395
+ this.listeners.delete(cb);
9396
+ };
9397
+ }
9398
+ subscribeCap(capName, cb) {
9399
+ let subs = this.capListeners.get(capName);
9400
+ if (!subs) {
9401
+ subs = /* @__PURE__ */ new Set();
9402
+ this.capListeners.set(capName, subs);
9403
+ }
9404
+ const adapter = (slice) => {
9405
+ cb(slice);
9406
+ };
9407
+ subs.add(adapter);
9408
+ return () => {
9409
+ const set = this.capListeners.get(capName);
9410
+ if (!set) return;
9411
+ set.delete(adapter);
9412
+ if (set.size === 0) this.capListeners.delete(capName);
9413
+ };
9414
+ }
9415
+ snapshot() {
9416
+ const out = {};
9417
+ for (const [k, v] of this.slices) out[k] = Object.freeze({ ...v });
9418
+ return Object.freeze(out);
9419
+ }
9420
+ async flush() {
9421
+ if (this.pendingWrites.size === 0) return;
9422
+ const inflight = [...this.pendingWrites];
9423
+ await Promise.allSettled(inflight);
9424
+ }
9425
+ };
9426
+ function shallowEqual(a, b) {
9427
+ const ak = Object.keys(a);
9428
+ const bk = Object.keys(b);
9429
+ if (ak.length !== bk.length) return false;
9430
+ for (const k of ak) if (a[k] !== b[k]) return false;
9431
+ return true;
9432
+ }
9029
9433
  new Set(["devices", "classes"]);
9030
9434
  /**
9031
9435
  * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
@@ -9218,7 +9622,68 @@ var NcZoneConditionSchema = object({
9218
9622
  * The P1 condition set — a flat AND of groups; absent group = pass;
9219
9623
  * membership lists are OR within the list (spec §2.3).
9220
9624
  */
9625
+ /**
9626
+ * What a rule may actuate.
9627
+ *
9628
+ * **No hand-maintained allowlist** (operator decision, and the right one — a
9629
+ * written list of methods is a third parallel map to keep aligned, and this
9630
+ * repo has paid for those). The boundary instead comes from a property the
9631
+ * capabilities already carry: an action may target only a **device-scoped**
9632
+ * capability method.
9633
+ *
9634
+ * That is not decoration. A rule can be authored by a NON-ADMIN — personal
9635
+ * rules are a supported flow — and the executor runs with the addon's
9636
+ * privileges, so an unbounded action is an arbitrary RPC channel with a
9637
+ * privilege escalation attached. Restricting to device scope excludes the
9638
+ * system caps (`device-manager.removeDevice` and friends) by construction,
9639
+ * costs nothing to maintain, and cannot rot: a cap that stops being
9640
+ * device-scoped stops being actuatable in the same change.
9641
+ *
9642
+ * The executor enforces it; {@link NcRuleActionSchema} carries the intent.
9643
+ */
9644
+ /**
9645
+ * One step of a sequence.
9646
+ *
9647
+ * `wait` is a first-class step rather than a property of the next action: it is
9648
+ * what makes a sequence a SEQUENCE and not a list — "unlock, wait 5s, open"
9649
+ * cannot be expressed otherwise.
9650
+ */
9651
+ var NcRuleActionSchema = discriminatedUnion("kind", [object({
9652
+ kind: literal("wait"),
9653
+ seconds: number().min(0).max(300)
9654
+ }), object({
9655
+ kind: literal("cap"),
9656
+ deviceId: number().int(),
9657
+ /** Capability name, e.g. `alarm-panel`. */
9658
+ cap: string().min(1),
9659
+ /** Method on it. The executor refuses a non-device-scoped cap. */
9660
+ method: string().min(1),
9661
+ /** Method arguments, minus `deviceId` (the executor injects it). */
9662
+ args: record(string(), unknown()).optional()
9663
+ })]);
9664
+ /**
9665
+ * Sequences a rule runs, by hook point.
9666
+ *
9667
+ * ONLY `onTrigger` is here, deliberately. The reference also has activation /
9668
+ * deactivation / reset / post-generation hooks, and they are wanted — but this
9669
+ * repo's expensive failure mode is declaring a surface nothing produces, so a
9670
+ * hook appears here in the same change that produces its edge, never before.
9671
+ */
9672
+ var NcRuleActionsSchema = object({
9673
+ /** Runs when the rule MATCHES. */
9674
+ onTrigger: array(object({
9675
+ name: string().min(1).max(120),
9676
+ enabled: boolean(),
9677
+ minDelaySec: number().int().min(0).max(86400).optional(),
9678
+ actions: array(NcRuleActionSchema).min(1)
9679
+ })).optional() });
9221
9680
  var NcConditionsSchema = object({
9681
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
9682
+ deviceState: object({
9683
+ deviceId: number().int(),
9684
+ /** Any of these matches. */
9685
+ states: array(string().min(1)).min(1)
9686
+ }).optional(),
9222
9687
  /** Device scope — absent = all devices. */
9223
9688
  devices: array(number()).optional(),
9224
9689
  /** Detector class names (any overlap with the record's class set). */
@@ -9512,7 +9977,16 @@ var NcRuleInputSchema = object({
9512
9977
  * read as `false` by {@link canSetGlobal} in the engine. Admins are not bound
9513
9978
  * by this flag — see the scope rules on that function.
9514
9979
  */
9515
- snoozeAllowGlobal: boolean().optional()
9980
+ snoozeAllowGlobal: boolean().optional(),
9981
+ /**
9982
+ * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
9983
+ *
9984
+ * This is what makes the rule set the alarm's trigger set without the alarm
9985
+ * being a special case: arming is
9986
+ * `{ cap: 'alarm-panel', method: 'arm', args: { mode: 'away' } }`, the same
9987
+ * shape as every other actuation.
9988
+ */
9989
+ actions: NcRuleActionsSchema.optional()
9516
9990
  });
9517
9991
  /**
9518
9992
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -9583,7 +10057,8 @@ var NcConditionDescriptorSchema = object({
9583
10057
  "packagePhase",
9584
10058
  "crossingSelect",
9585
10059
  "polygonDraw",
9586
- "occupancy"
10060
+ "occupancy",
10061
+ "deviceState"
9587
10062
  ]),
9588
10063
  operator: _enum([
9589
10064
  "in",
@@ -9842,6 +10317,21 @@ var NC_CONDITION_CATALOG = [
9842
10317
  phase: "P1",
9843
10318
  description: "pipeline / onboard / sensor; a record with no stamped source counts as pipeline."
9844
10319
  },
10320
+ {
10321
+ id: "deviceState",
10322
+ group: "scope",
10323
+ label: "Device state",
10324
+ valueType: "deviceState",
10325
+ operator: "in",
10326
+ appliesTo: [
10327
+ "immediate",
10328
+ "track-end",
10329
+ "device-event",
10330
+ "package-event"
10331
+ ],
10332
+ phase: "P2",
10333
+ description: "Only fire while another device is in one of the chosen states — the alarm armed, a switch on, a contact closed. A state that cannot be read does NOT fire."
10334
+ },
9845
10335
  {
9846
10336
  id: "sensorKinds",
9847
10337
  group: "device",
@@ -10294,10 +10784,25 @@ var DeviceStatusSchema = object({
10294
10784
  * apart "just came online" from "still online". */
10295
10785
  lastChangedAt: number()
10296
10786
  });
10297
- object({
10298
- deviceId: number(),
10299
- status: DeviceStatusSchema
10300
- });
10787
+ var deviceStatusCapability = {
10788
+ name: "device-status",
10789
+ scope: "device",
10790
+ deviceNative: true,
10791
+ mode: "singleton",
10792
+ methods: {},
10793
+ events: {
10794
+ /** Emitted when `online` transitions. Mirrors the semantics of
10795
+ * `battery.onStatusChanged`. */
10796
+ onStatusChanged: { data: object({
10797
+ deviceId: number(),
10798
+ status: DeviceStatusSchema
10799
+ }) } },
10800
+ status: {
10801
+ schema: DeviceStatusSchema,
10802
+ kind: "push"
10803
+ },
10804
+ runtimeState: DeviceStatusSchema
10805
+ };
10301
10806
  /**
10302
10807
  * Per-device feature/identity probe slice. Holds the runtime-resolved
10303
10808
  * truth about what a device CAN do — which the kernel uses to:
@@ -10355,11 +10860,35 @@ var FeatureProbeStatusSchema = object({
10355
10860
  */
10356
10861
  lastFetchedAt: number()
10357
10862
  });
10358
- object({
10359
- deviceId: number(),
10360
- status: FeatureProbeStatusSchema
10361
- });
10362
- object({
10863
+ var featureProbeCapability = {
10864
+ name: "feature-probe",
10865
+ scope: "device",
10866
+ deviceNative: true,
10867
+ mode: "singleton",
10868
+ methods: {},
10869
+ events: {
10870
+ /** Fires whenever a fresh probe completes (kernel-driven `reprobe()`
10871
+ * or driver-initiated re-detect after a state change). */
10872
+ onProbeChanged: { data: object({
10873
+ deviceId: number(),
10874
+ status: FeatureProbeStatusSchema
10875
+ }) } },
10876
+ status: {
10877
+ schema: FeatureProbeStatusSchema,
10878
+ kind: "push"
10879
+ },
10880
+ runtimeState: FeatureProbeStatusSchema
10881
+ };
10882
+ /**
10883
+ * Multi-metric air-quality slice. Covers CO₂, total VOCs, particulate
10884
+ * matter at PM2.5 / PM10, and a derived AQI index — all optional so
10885
+ * a single-metric source populates only what it observes. Mirrors
10886
+ * the HA `sensor` device_class set (`co2`, `volatile_organic_compounds`,
10887
+ * `pm25`, `pm10`, `aqi`) collapsed into one cap because a typical
10888
+ * air-quality node reports several of these together; modelling them
10889
+ * as siblings keeps a single timestamp + one slice subscription.
10890
+ */
10891
+ var AirQualitySensorStatusSchema = object({
10363
10892
  /** Carbon dioxide concentration in ppm. */
10364
10893
  co2Ppm: number().min(0).optional(),
10365
10894
  /** Total volatile organic compounds in ppb. */
@@ -10383,7 +10912,19 @@ object({
10383
10912
  * auto-formatting when absent. */
10384
10913
  precision: number().int().min(0).max(10).optional()
10385
10914
  });
10386
- DeviceType.Sensor;
10915
+ var airQualitySensorCapability = {
10916
+ name: "air-quality-sensor",
10917
+ scope: "device",
10918
+ deviceNative: true,
10919
+ mode: "singleton",
10920
+ deviceTypes: [DeviceType.Sensor],
10921
+ methods: {},
10922
+ status: {
10923
+ schema: AirQualitySensorStatusSchema,
10924
+ kind: "push"
10925
+ },
10926
+ runtimeState: AirQualitySensorStatusSchema
10927
+ };
10387
10928
  /**
10388
10929
  * Alarm-panel cap. Models HA `alarm_control_panel.*` on
10389
10930
  * `DeviceType.AlarmPanel`. State follows HA's canonical lifecycle
@@ -10419,7 +10960,7 @@ var AlarmArmModeSchema = _enum([
10419
10960
  "vacation",
10420
10961
  "custom_bypass"
10421
10962
  ]);
10422
- object({
10963
+ var AlarmPanelStatusSchema = object({
10423
10964
  /** Current lifecycle state. */
10424
10965
  state: AlarmStateSchema,
10425
10966
  /** Subset of arm modes the panel accepts. UI renders one button per
@@ -10431,26 +10972,58 @@ object({
10431
10972
  /** Ms epoch when the slice was last updated. */
10432
10973
  lastChangedAt: number()
10433
10974
  });
10434
- DeviceType.AlarmPanel, method(object({
10435
- deviceId: number().int().nonnegative(),
10436
- mode: AlarmArmModeSchema,
10437
- /** Optional PIN code. Required when `requiresCode === true`.
10438
- * Passed through to the upstream service; never persisted. */
10439
- code: string().min(1).optional()
10440
- }), _void(), {
10441
- kind: "mutation",
10442
- auth: "admin"
10443
- }), method(object({
10444
- deviceId: number().int().nonnegative(),
10445
- code: string().min(1).optional()
10446
- }), _void(), {
10447
- kind: "mutation",
10448
- auth: "admin"
10449
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
10450
- kind: "mutation",
10451
- auth: "admin"
10452
- });
10453
- object({
10975
+ var alarmPanelCapability = {
10976
+ name: "alarm-panel",
10977
+ scope: "device",
10978
+ deviceNative: true,
10979
+ mode: "singleton",
10980
+ deviceTypes: [DeviceType.AlarmPanel],
10981
+ methods: {
10982
+ arm: method(object({
10983
+ deviceId: number().int().nonnegative(),
10984
+ mode: AlarmArmModeSchema,
10985
+ /** Optional PIN code. Required when `requiresCode === true`.
10986
+ * Passed through to the upstream service; never persisted. */
10987
+ code: string().min(1).optional()
10988
+ }), _void(), {
10989
+ kind: "mutation",
10990
+ auth: "admin"
10991
+ }),
10992
+ disarm: method(object({
10993
+ deviceId: number().int().nonnegative(),
10994
+ code: string().min(1).optional()
10995
+ }), _void(), {
10996
+ kind: "mutation",
10997
+ auth: "admin"
10998
+ }),
10999
+ /**
11000
+ * Force the panel into the `triggered` state — used by HA
11001
+ * automations to surface external sensor events through the panel
11002
+ * (e.g. a Reolink camera intrusion event firing the security
11003
+ * system). Provider rejects when the panel hardware doesn't
11004
+ * support a software-initiated trigger.
11005
+ */
11006
+ trigger: method(object({ deviceId: number().int().nonnegative() }), _void(), {
11007
+ kind: "mutation",
11008
+ auth: "admin"
11009
+ })
11010
+ },
11011
+ status: {
11012
+ schema: AlarmPanelStatusSchema,
11013
+ kind: "push"
11014
+ },
11015
+ /**
11016
+ * Runtime-state slice — mirrored by the kernel. UI panel reads the
11017
+ * full slice; renders an arm button per `availableModes` entry and
11018
+ * a PIN field iff `requiresCode === true`.
11019
+ */
11020
+ runtimeState: AlarmPanelStatusSchema
11021
+ };
11022
+ /**
11023
+ * Ambient illuminance reading in lux. Drives Home Assistant `sensor`
11024
+ * entries with `device_class: illuminance`.
11025
+ */
11026
+ var AmbientLightSensorStatusSchema = object({
10454
11027
  /** Current illuminance in lux (lx). */
10455
11028
  lux: number().min(0),
10456
11029
  /** Ms epoch when the slice was last updated. */
@@ -10465,7 +11038,19 @@ object({
10465
11038
  * auto-formatting when absent. */
10466
11039
  precision: number().int().min(0).max(10).optional()
10467
11040
  });
10468
- DeviceType.Sensor;
11041
+ var ambientLightSensorCapability = {
11042
+ name: "ambient-light-sensor",
11043
+ scope: "device",
11044
+ deviceNative: true,
11045
+ mode: "singleton",
11046
+ deviceTypes: [DeviceType.Sensor],
11047
+ methods: {},
11048
+ status: {
11049
+ schema: AmbientLightSensorStatusSchema,
11050
+ kind: "push"
11051
+ },
11052
+ runtimeState: AmbientLightSensorStatusSchema
11053
+ };
10469
11054
  /**
10470
11055
  * Per-class audio metrics aggregated over a sliding window.
10471
11056
  */
@@ -10584,7 +11169,18 @@ var audioMetricsCapability = {
10584
11169
  /** Reactive runtime-state mirror — live `device.state.audioMetrics.value`. */
10585
11170
  runtimeState: AudioMetricsSnapshotSchema
10586
11171
  };
10587
- object({
11172
+ /**
11173
+ * Automation-control cap. Models HA `automation.*` entities on
11174
+ * `DeviceType.Automation`. An automation is a trigger+condition+
11175
+ * action rule that can be enabled / disabled and manually fired
11176
+ * via the `trigger` method.
11177
+ *
11178
+ * `trigger` accepts an optional `skipCondition` flag — when true,
11179
+ * the automation's action block runs WITHOUT evaluating its
11180
+ * condition block. Pair with `DeviceFeature.AutomationSkipCondition`
11181
+ * to gate the UI checkbox for the manual-trigger dialog.
11182
+ */
11183
+ var AutomationControlStatusSchema = object({
10588
11184
  /** Whether the automation is currently enabled. Disabled automations
10589
11185
  * ignore their trigger block — manual `trigger` still works. */
10590
11186
  enabled: boolean(),
@@ -10598,22 +11194,43 @@ object({
10598
11194
  /** Ms epoch when the slice was last updated. */
10599
11195
  lastChangedAt: number()
10600
11196
  });
10601
- DeviceType.Automation, method(object({ deviceId: number().int().nonnegative() }), _void(), {
10602
- kind: "mutation",
10603
- auth: "admin"
10604
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
10605
- kind: "mutation",
10606
- auth: "admin"
10607
- }), method(object({
10608
- deviceId: number().int().nonnegative(),
10609
- /** When true, fires the action block while bypassing the
10610
- * automation's condition evaluation. Gated by
10611
- * `DeviceFeature.AutomationSkipCondition`. */
10612
- skipCondition: boolean().optional()
10613
- }), _void(), {
10614
- kind: "mutation",
10615
- auth: "admin"
10616
- });
11197
+ var automationControlCapability = {
11198
+ name: "automation-control",
11199
+ scope: "device",
11200
+ deviceNative: true,
11201
+ mode: "singleton",
11202
+ deviceTypes: [DeviceType.Automation],
11203
+ methods: {
11204
+ enable: method(object({ deviceId: number().int().nonnegative() }), _void(), {
11205
+ kind: "mutation",
11206
+ auth: "admin"
11207
+ }),
11208
+ disable: method(object({ deviceId: number().int().nonnegative() }), _void(), {
11209
+ kind: "mutation",
11210
+ auth: "admin"
11211
+ }),
11212
+ trigger: method(object({
11213
+ deviceId: number().int().nonnegative(),
11214
+ /** When true, fires the action block while bypassing the
11215
+ * automation's condition evaluation. Gated by
11216
+ * `DeviceFeature.AutomationSkipCondition`. */
11217
+ skipCondition: boolean().optional()
11218
+ }), _void(), {
11219
+ kind: "mutation",
11220
+ auth: "admin"
11221
+ })
11222
+ },
11223
+ status: {
11224
+ schema: AutomationControlStatusSchema,
11225
+ kind: "push"
11226
+ },
11227
+ /**
11228
+ * Runtime-state slice — mirrored by the kernel. UI automation tile
11229
+ * reads `enabled` (toggle) + `isRunning` (spinner) + `lastError`
11230
+ * (badge) directly.
11231
+ */
11232
+ runtimeState: AutomationControlStatusSchema
11233
+ };
10617
11234
  /**
10618
11235
  * Battery status snapshot. Emitted by providers whose device is
10619
11236
  * battery-operated (cameras with `DeviceFeature.BatteryOperated`,
@@ -10652,40 +11269,156 @@ var BatteryStatusSchema = object({
10652
11269
  */
10653
11270
  binary: boolean().optional()
10654
11271
  });
10655
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, method(object({
10656
- deviceId: number(),
10657
- /** Bound on the wait. Sensible range 3000–10000ms. */
10658
- timeoutMs: number().int().min(500).max(3e4).default(8e3)
10659
- }), object({
10660
- awoke: boolean(),
10661
- durationMs: number()
10662
- }), { kind: "mutation" }), object({
10663
- deviceId: number(),
10664
- status: BatteryStatusSchema
10665
- });
10666
- object({
11272
+ var batteryCapability = {
11273
+ name: "battery",
11274
+ scope: "device",
11275
+ deviceNative: true,
11276
+ mode: "singleton",
11277
+ deviceTypes: [
11278
+ DeviceType.Camera,
11279
+ DeviceType.Sensor,
11280
+ DeviceType.Button,
11281
+ DeviceType.Switch
11282
+ ],
11283
+ methods: {
11284
+ /**
11285
+ * Explicitly wake the camera from low-power sleep ahead of a
11286
+ * streaming session start. Consumers that initiate a stream
11287
+ * against a sleeping battery cam (HomeKit Secure Video, Alexa
11288
+ * RTCSession, snapshot wrappers) call this with a short timeout
11289
+ * before establishing the media pipeline — the broker's own
11290
+ * passive wake-on-dial works but adds 5–7 seconds to first-frame,
11291
+ * during which the consumer renders a black screen. Pre-waking
11292
+ * compresses that gap.
11293
+ *
11294
+ * Returns `awoke: true` when the firmware acknowledged the wake
11295
+ * before `timeoutMs`. Returns `awoke: false` when it timed out OR
11296
+ * the cap surface is unavailable (no Baichuan / firmware
11297
+ * channel); the caller should still attempt the stream — the
11298
+ * passive broker wake remains as fallback.
11299
+ */
11300
+ wakeForStream: method(object({
11301
+ deviceId: number(),
11302
+ /** Bound on the wait. Sensible range 3000–10000ms. */
11303
+ timeoutMs: number().int().min(500).max(3e4).default(8e3)
11304
+ }), object({
11305
+ awoke: boolean(),
11306
+ durationMs: number()
11307
+ }), { kind: "mutation" }) },
11308
+ events: {
11309
+ /**
11310
+ * Emitted whenever the cached status changes (firmware push OR
11311
+ * poll observes a delta). The DeviceEventPropagator mirrors this
11312
+ * event on the parent chain — subscribing to a camera's source
11313
+ * receives battery events from child accessories automatically.
11314
+ */
11315
+ onStatusChanged: { data: object({
11316
+ deviceId: number(),
11317
+ status: BatteryStatusSchema
11318
+ }) } },
11319
+ status: {
11320
+ schema: BatteryStatusSchema,
11321
+ kind: "push",
11322
+ empty: {
11323
+ percentage: 0,
11324
+ charging: "none",
11325
+ sleeping: false,
11326
+ lastUpdated: 0
11327
+ }
11328
+ },
11329
+ /**
11330
+ * Runtime-state slice — every provider that registers this cap
11331
+ * stores the same shape under `device.runtimeState[battery]`.
11332
+ * Cross-provider uniformity: a Reolink Argus, a Frigate sensor
11333
+ * proxy, an ONVIF battery cam all read/write the same keys.
11334
+ * Consumers (BatteryBadge, snapshot wrapper sleep gate) read once
11335
+ * via `device.runtimeState.getCapState('battery')` regardless of
11336
+ * the underlying driver.
11337
+ */
11338
+ runtimeState: BatteryStatusSchema
11339
+ };
11340
+ /**
11341
+ * Generic boolean sensor — last-resort fallback when no domain-
11342
+ * specific binary cap fits (Home Assistant `binary_sensor` without a
11343
+ * known `device_class`, or a domain we haven't typed yet). Pure
11344
+ * pass-through: just the bool + timestamp. Push-driven.
11345
+ *
11346
+ * Prefer the typed alternatives (`contact`, `flood`, `smoke`,
11347
+ * `carbon-monoxide`, `gas`, `tamper`, `vibration`, `connectivity`,
11348
+ * `motion`) when the semantics match — export adapters render those
11349
+ * with the right HomeKit / Alexa display category.
11350
+ */
11351
+ var BinaryStatusSchema = object({
10667
11352
  on: boolean(),
10668
11353
  /** Ms epoch of the last transition. 0 if never observed. */
10669
11354
  lastChangedAt: number()
10670
11355
  });
10671
- DeviceType.Sensor;
10672
- object({
11356
+ var binaryCapability = {
11357
+ name: "binary",
11358
+ scope: "device",
11359
+ deviceNative: true,
11360
+ mode: "singleton",
11361
+ deviceTypes: [DeviceType.Sensor],
11362
+ methods: {},
11363
+ status: {
11364
+ schema: BinaryStatusSchema,
11365
+ kind: "push"
11366
+ },
11367
+ runtimeState: BinaryStatusSchema
11368
+ };
11369
+ /**
11370
+ * Dimmable-light brightness control. Co-exists with `switch` on the
11371
+ * same device — the switch toggles on/off, this cap sets the level
11372
+ * applied when the light is on. Drivers map their per-vendor dim
11373
+ * controls to this single-method surface.
11374
+ *
11375
+ * The cap is intentionally minimal: a single `setBrightness({deviceId,
11376
+ * percentage})` mutation plus the auto-injected `getStatus`. Drivers
11377
+ * that expose richer controls (color temperature, scenes, schedules)
11378
+ * should surface those via the device's `getSettingsUISchema()`
11379
+ * instead of bloating this cap.
11380
+ */
11381
+ var BrightnessStatusSchema = object({
10673
11382
  /** Current level as 0..100 inclusive. Firmware-reported. */
10674
11383
  percentage: number().min(0).max(100),
10675
11384
  /** Ms epoch of the last operator-driven change. Useful for UI freshness. */
10676
11385
  lastChangedAt: number()
10677
11386
  });
10678
- DeviceType.Light, method(object({
10679
- deviceId: number().int().nonnegative(),
10680
- percentage: number().min(0).max(100)
10681
- }), _void(), {
10682
- kind: "mutation",
10683
- auth: "admin"
10684
- }), object({
10685
- deviceId: number(),
10686
- percentage: number().min(0).max(100),
10687
- lastChangedAt: number()
10688
- });
11387
+ var brightnessCapability = {
11388
+ name: "brightness",
11389
+ scope: "device",
11390
+ deviceNative: true,
11391
+ mode: "singleton",
11392
+ deviceTypes: [DeviceType.Light],
11393
+ methods: { setBrightness: method(object({
11394
+ deviceId: number().int().nonnegative(),
11395
+ percentage: number().min(0).max(100)
11396
+ }), _void(), {
11397
+ kind: "mutation",
11398
+ auth: "admin"
11399
+ }) },
11400
+ events: {
11401
+ /**
11402
+ * Emitted whenever the brightness changes — operator action OR
11403
+ * firmware push. Subscribers (UI sliders, automation engines) react
11404
+ * without polling.
11405
+ */
11406
+ onBrightnessChanged: { data: object({
11407
+ deviceId: number(),
11408
+ percentage: number().min(0).max(100),
11409
+ lastChangedAt: number()
11410
+ }) } },
11411
+ status: {
11412
+ schema: BrightnessStatusSchema,
11413
+ kind: "command-driven"
11414
+ },
11415
+ /**
11416
+ * Runtime-state slice — the last applied brightness level, mirrored
11417
+ * by the kernel. Read via `device.state.brightness.value` so UI
11418
+ * sliders surface the current level without polling the provider.
11419
+ */
11420
+ runtimeState: BrightnessStatusSchema
11421
+ };
10689
11422
  /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
10690
11423
  var StreamFormatSchema = _enum([
10691
11424
  "webrtc",
@@ -11105,44 +11838,155 @@ var PickedCamStreamSchema = object({
11105
11838
  /** One-line explanation of why this stream won — for logs / debug UI. */
11106
11839
  reason: string()
11107
11840
  });
11108
- DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
11109
- deviceId: number().int().nonnegative(),
11110
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11111
- hostname: string().optional()
11112
- }), array(RtspRestreamEntrySchema).readonly()), method(object({
11113
- deviceId: number().int().nonnegative(),
11114
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11115
- hostname: string().optional()
11116
- }), array(ProfileRtspEntrySchema).readonly()), method(object({
11117
- deviceId: number().int().nonnegative(),
11118
- requirements: PickStreamRequirementsSchema,
11119
- preferences: PickStreamPreferencesSchema.optional()
11120
- }), PickedCamStreamSchema.nullable()), object({
11121
- deviceId: number().int().nonnegative(),
11122
- camStreams: array(CameraStreamSchema).readonly()
11123
- }), object({
11124
- deviceId: number().int().nonnegative(),
11125
- profileSlots: array(ProfileSlotSchema).readonly()
11126
- }), object({
11127
- online: boolean(),
11128
- slotStatuses: object({
11129
- high: ProfileSlotStatusSchema.optional(),
11130
- mid: ProfileSlotStatusSchema.optional(),
11131
- low: ProfileSlotStatusSchema.optional()
11132
- }),
11133
- slotErrors: object({
11134
- high: string().optional(),
11135
- mid: string().optional(),
11136
- low: string().optional()
11137
- }),
11138
- lastChangedAt: number()
11139
- });
11140
- object({
11841
+ /**
11842
+ * Camera streams — device-scoped facade over the system `stream-broker`.
11843
+ *
11844
+ * Mirrors the slice of broker state relevant to a single device:
11845
+ * - `getCameraStreams()`: the pool of physical streams published for
11846
+ * this device. UI uses it to populate the "Camera Stream" dropdown
11847
+ * under each quality section.
11848
+ * - `getBrokerStreams()`: the (up to 3) profile slots `high/mid/low`
11849
+ * with their current assignment + runtime status. UI uses it for
11850
+ * the WebRTC quality picker, recording target selection, etc.
11851
+ *
11852
+ * Registered for every camera device that has at least one published
11853
+ * cam stream. The provider is owned by the stream-broker addon; reads
11854
+ * go against the broker's in-memory registries. Mutations (assign /
11855
+ * unassign / publish / retract) do NOT live here — they stay on the
11856
+ * system `stream-broker` cap so cross-device / addon-driven flows keep
11857
+ * a single namespace.
11858
+ */
11859
+ var cameraStreamsCapability = {
11860
+ name: "camera-streams",
11861
+ scope: "device",
11862
+ mode: "singleton",
11863
+ kind: "wrapper",
11864
+ defaultActive: true,
11865
+ deviceTypes: [DeviceType.Camera],
11866
+ methods: {
11867
+ getCameraStreams: method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()),
11868
+ getBrokerStreams: method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()),
11869
+ /**
11870
+ * Per-device RAW RTSP restream entries — one per published camStream
11871
+ * that has RTSP restream enabled (`native:main`, `rtsp:sub`, …).
11872
+ *
11873
+ * LIVE-VIEW ONLY. This is the surface the device-details stream
11874
+ * picker uses so an operator can hit each physical stream directly.
11875
+ * Programmatic / external consumers (HAP, Alexa, ha-mqtt, recording)
11876
+ * MUST use `getProfileRtspEntries` instead — picking from raw
11877
+ * variants makes two consumers of the same camera land on two
11878
+ * different physical pulls (e.g. Reolink `native:main` vs
11879
+ * `rtsp:main`) and trips the camera's concurrent-session limit.
11880
+ */
11881
+ getRtspEntries: method(object({
11882
+ deviceId: number().int().nonnegative(),
11883
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11884
+ hostname: string().optional()
11885
+ }), array(RtspRestreamEntrySchema).readonly()),
11886
+ /**
11887
+ * Per-device PROFILE RTSP restream entries — one per ASSIGNED
11888
+ * profile slot (high/mid/low). Each entry's `url` is a profile-keyed
11889
+ * broker restream that aliases the profile's assigned source broker,
11890
+ * so HAP / Alexa / recording / WebRTC all converge on the broker's
11891
+ * single on-demand pull for that profile. This is the supported
11892
+ * exporter-facing surface; the raw `getRtspEntries` is live-view
11893
+ * only. Returns `[]` for a device with no assigned profiles
11894
+ * (cold-start before first publish).
11895
+ */
11896
+ getProfileRtspEntries: method(object({
11897
+ deviceId: number().int().nonnegative(),
11898
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
11899
+ hostname: string().optional()
11900
+ }), array(ProfileRtspEntrySchema).readonly()),
11901
+ /**
11902
+ * "Best source stream for these decode constraints". Returns the
11903
+ * camStreamId the caller should dial (or null when no stream
11904
+ * matches). See `PickStreamRequirementsSchema` for the filter shape
11905
+ * and `PickStreamPreferencesSchema` for the ranking inputs.
11906
+ *
11907
+ * Returning null instructs the caller to fall back to its existing
11908
+ * path (derived-broker transcode, profile-slot pick, etc.) — the
11909
+ * picker NEVER ranks `derived:*` candidates as a "match" because
11910
+ * its whole job is to avoid the transcode.
11911
+ */
11912
+ pickStream: method(object({
11913
+ deviceId: number().int().nonnegative(),
11914
+ requirements: PickStreamRequirementsSchema,
11915
+ preferences: PickStreamPreferencesSchema.optional()
11916
+ }), PickedCamStreamSchema.nullable())
11917
+ },
11918
+ events: {
11919
+ /** Fires on publishCameraStream / retractCameraStream. */
11920
+ onCamStreamsChanged: event(object({
11921
+ deviceId: number().int().nonnegative(),
11922
+ camStreams: array(CameraStreamSchema).readonly()
11923
+ })),
11924
+ /** Fires on assignProfile / unassignProfile / runtime status change. */
11925
+ onProfileSlotsChanged: event(object({
11926
+ deviceId: number().int().nonnegative(),
11927
+ profileSlots: array(ProfileSlotSchema).readonly()
11928
+ }))
11929
+ },
11930
+ /**
11931
+ * Per-device live stream-broker state. Persistent settings (RTSP
11932
+ * tokens, profile assignments, pre-buffer config, RTSP-enabled toggles,
11933
+ * streamingDebug) stay in the broker's addon store — they survive
11934
+ * restarts. The slice below carries ONLY what's truly runtime:
11935
+ *
11936
+ * - `online` — at least one profile slot is currently `'streaming'`.
11937
+ * Drivers without a firmware liveness signal (RTSP, ONVIF…) can
11938
+ * subscribe and mirror this into `state.deviceStatus.online`.
11939
+ * - `slotStatuses` — current `ProfileSlotStatus` per profile,
11940
+ * mirroring the runtime-mutable subset of `ProfileSlot`.
11941
+ * - `slotErrors` — last error message per profile (only set when the
11942
+ * corresponding slot is in `'error'`).
11943
+ * - `lastChangedAt` — freshness signal for consumers that want to
11944
+ * reason about how stale the slice is.
11945
+ *
11946
+ * Written by the stream-broker manager on every transition that
11947
+ * affects these aggregates. Read via `device.state.cameraStreams.<field>`
11948
+ * (BaseDevice proxy) or, cross-process, via
11949
+ * `device-state.getCapSlice({deviceId, capName: 'camera-streams'})`.
11950
+ * The cap's `onChanged` event fires automatically on each write so
11951
+ * subscribers get push semantics for free.
11952
+ */
11953
+ runtimeState: object({
11954
+ online: boolean(),
11955
+ slotStatuses: object({
11956
+ high: ProfileSlotStatusSchema.optional(),
11957
+ mid: ProfileSlotStatusSchema.optional(),
11958
+ low: ProfileSlotStatusSchema.optional()
11959
+ }),
11960
+ slotErrors: object({
11961
+ high: string().optional(),
11962
+ mid: string().optional(),
11963
+ low: string().optional()
11964
+ }),
11965
+ lastChangedAt: number()
11966
+ })
11967
+ };
11968
+ /**
11969
+ * Carbon-monoxide alarm sensor. Drives Home Assistant `binary_sensor`
11970
+ * entries with `device_class: carbon_monoxide`. Push-driven.
11971
+ */
11972
+ var CarbonMonoxideStatusSchema = object({
11141
11973
  detected: boolean(),
11142
11974
  /** Ms epoch of the last transition. 0 if never observed. */
11143
11975
  lastChangedAt: number()
11144
11976
  });
11145
- DeviceType.Sensor;
11977
+ var carbonMonoxideCapability = {
11978
+ name: "carbon-monoxide",
11979
+ scope: "device",
11980
+ deviceNative: true,
11981
+ mode: "singleton",
11982
+ deviceTypes: [DeviceType.Sensor],
11983
+ methods: {},
11984
+ status: {
11985
+ schema: CarbonMonoxideStatusSchema,
11986
+ kind: "push"
11987
+ },
11988
+ runtimeState: CarbonMonoxideStatusSchema
11989
+ };
11146
11990
  /**
11147
11991
  * HVAC / climate control cap. Models the full surface of a HA
11148
11992
  * `climate.*` entity (thermostat, A/C, heat pump, dehumidifier)
@@ -11183,7 +12027,7 @@ var HvacModeSchema = _enum([
11183
12027
  "fan_only",
11184
12028
  "dry"
11185
12029
  ]);
11186
- object({
12030
+ var ClimateControlStatusSchema = object({
11187
12031
  /** Active HVAC mode. */
11188
12032
  mode: HvacModeSchema,
11189
12033
  /** Available HVAC modes the device accepts. Subset of HvacMode. */
@@ -11227,56 +12071,82 @@ object({
11227
12071
  /** Ms epoch when the slice was last updated (push or command). */
11228
12072
  lastFetchedAt: number()
11229
12073
  });
11230
- DeviceType.Thermostat, DeviceType.Climate, method(object({
11231
- deviceId: number().int().nonnegative(),
11232
- mode: HvacModeSchema
11233
- }), _void(), {
11234
- kind: "mutation",
11235
- auth: "admin"
11236
- }), method(object({
11237
- deviceId: number().int().nonnegative(),
11238
- fanMode: string().min(1)
11239
- }), _void(), {
11240
- kind: "mutation",
11241
- auth: "admin"
11242
- }), method(object({
11243
- deviceId: number().int().nonnegative(),
11244
- preset: string().min(1)
11245
- }), _void(), {
11246
- kind: "mutation",
11247
- auth: "admin"
11248
- }), method(object({
11249
- deviceId: number().int().nonnegative(),
11250
- target: number()
11251
- }), _void(), {
11252
- kind: "mutation",
11253
- auth: "admin"
11254
- }), method(object({
11255
- deviceId: number().int().nonnegative(),
11256
- targetLow: number(),
11257
- targetHigh: number()
11258
- }), _void(), {
11259
- kind: "mutation",
11260
- auth: "admin"
11261
- }), method(object({
11262
- deviceId: number().int().nonnegative(),
11263
- targetHumidity: number().min(0).max(100)
11264
- }), _void(), {
11265
- kind: "mutation",
11266
- auth: "admin"
11267
- }), method(object({
11268
- deviceId: number().int().nonnegative(),
11269
- on: boolean()
11270
- }), _void(), {
11271
- kind: "mutation",
11272
- auth: "admin"
11273
- }), method(object({
11274
- deviceId: number().int().nonnegative(),
11275
- on: boolean()
11276
- }), _void(), {
11277
- kind: "mutation",
11278
- auth: "admin"
11279
- });
12074
+ var climateControlCapability = {
12075
+ name: "climate-control",
12076
+ scope: "device",
12077
+ deviceNative: true,
12078
+ mode: "singleton",
12079
+ deviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
12080
+ methods: {
12081
+ setMode: method(object({
12082
+ deviceId: number().int().nonnegative(),
12083
+ mode: HvacModeSchema
12084
+ }), _void(), {
12085
+ kind: "mutation",
12086
+ auth: "admin"
12087
+ }),
12088
+ setFanMode: method(object({
12089
+ deviceId: number().int().nonnegative(),
12090
+ fanMode: string().min(1)
12091
+ }), _void(), {
12092
+ kind: "mutation",
12093
+ auth: "admin"
12094
+ }),
12095
+ setPreset: method(object({
12096
+ deviceId: number().int().nonnegative(),
12097
+ preset: string().min(1)
12098
+ }), _void(), {
12099
+ kind: "mutation",
12100
+ auth: "admin"
12101
+ }),
12102
+ setTarget: method(object({
12103
+ deviceId: number().int().nonnegative(),
12104
+ target: number()
12105
+ }), _void(), {
12106
+ kind: "mutation",
12107
+ auth: "admin"
12108
+ }),
12109
+ setTargetRange: method(object({
12110
+ deviceId: number().int().nonnegative(),
12111
+ targetLow: number(),
12112
+ targetHigh: number()
12113
+ }), _void(), {
12114
+ kind: "mutation",
12115
+ auth: "admin"
12116
+ }),
12117
+ setTargetHumidity: method(object({
12118
+ deviceId: number().int().nonnegative(),
12119
+ targetHumidity: number().min(0).max(100)
12120
+ }), _void(), {
12121
+ kind: "mutation",
12122
+ auth: "admin"
12123
+ }),
12124
+ setSwingVertical: method(object({
12125
+ deviceId: number().int().nonnegative(),
12126
+ on: boolean()
12127
+ }), _void(), {
12128
+ kind: "mutation",
12129
+ auth: "admin"
12130
+ }),
12131
+ setSwingHorizontal: method(object({
12132
+ deviceId: number().int().nonnegative(),
12133
+ on: boolean()
12134
+ }), _void(), {
12135
+ kind: "mutation",
12136
+ auth: "admin"
12137
+ })
12138
+ },
12139
+ status: {
12140
+ schema: ClimateControlStatusSchema,
12141
+ kind: "push"
12142
+ },
12143
+ /**
12144
+ * Runtime-state slice — mirrored by the kernel. UI thermostats read
12145
+ * the full slice via `device.state.climate-control.value` and refresh
12146
+ * on every push without re-querying the provider.
12147
+ */
12148
+ runtimeState: ClimateControlStatusSchema
12149
+ };
11280
12150
  /**
11281
12151
  * Color-light cap. Coexists with `switch` (on/off) and `brightness`
11282
12152
  * (level) on the same device — `switch` toggles the bulb, `brightness`
@@ -11328,7 +12198,7 @@ var ColorInputSchema = discriminatedUnion("mode", [
11328
12198
  mireds: number().int().min(50).max(1e3)
11329
12199
  })
11330
12200
  ]);
11331
- object({
12201
+ var ColorStatusSchema = object({
11332
12202
  /** Active color mode — which of `rgb` / `hsv` / `mireds` reflects
11333
12203
  * the bulb's current state. */
11334
12204
  mode: _enum([
@@ -11345,63 +12215,209 @@ object({
11345
12215
  /** Ms epoch of the last operator-driven change. */
11346
12216
  lastChangedAt: number()
11347
12217
  });
11348
- DeviceType.Light, method(object({
11349
- deviceId: number().int().nonnegative(),
11350
- color: ColorInputSchema
11351
- }), _void(), {
11352
- kind: "mutation",
11353
- auth: "admin"
11354
- }), object({
11355
- deviceId: number(),
11356
- mode: _enum([
11357
- "rgb",
11358
- "hsv",
11359
- "mired"
11360
- ]),
11361
- rgb: RgbTripletSchema.optional(),
11362
- hsv: HsvTripletSchema.optional(),
11363
- mireds: number().int().optional(),
11364
- lastChangedAt: number()
11365
- });
11366
- object({
12218
+ var colorCapability = {
12219
+ name: "color",
12220
+ scope: "device",
12221
+ deviceNative: true,
12222
+ mode: "singleton",
12223
+ deviceTypes: [DeviceType.Light],
12224
+ methods: { setColor: method(object({
12225
+ deviceId: number().int().nonnegative(),
12226
+ color: ColorInputSchema
12227
+ }), _void(), {
12228
+ kind: "mutation",
12229
+ auth: "admin"
12230
+ }) },
12231
+ events: {
12232
+ /**
12233
+ * Emitted whenever the color changes — operator action OR firmware
12234
+ * push. Subscribers (UI color pickers, automation engines) react
12235
+ * without polling.
12236
+ */
12237
+ onColorChanged: { data: object({
12238
+ deviceId: number(),
12239
+ mode: _enum([
12240
+ "rgb",
12241
+ "hsv",
12242
+ "mired"
12243
+ ]),
12244
+ rgb: RgbTripletSchema.optional(),
12245
+ hsv: HsvTripletSchema.optional(),
12246
+ mireds: number().int().optional(),
12247
+ lastChangedAt: number()
12248
+ }) } },
12249
+ status: {
12250
+ schema: ColorStatusSchema,
12251
+ kind: "command-driven"
12252
+ },
12253
+ /**
12254
+ * Runtime-state slice — the last applied color, mirrored by the
12255
+ * kernel. Read via `device.state.color.value` so UI pickers surface
12256
+ * the current chromaticity without polling the provider.
12257
+ */
12258
+ runtimeState: ColorStatusSchema
12259
+ };
12260
+ /**
12261
+ * Upstream-system connectivity sensor — distinct from `device-status`,
12262
+ * which is the kernel-managed online/offline flag for the device's
12263
+ * own transport. This cap surfaces an external entity's view of
12264
+ * whether the device is reachable (typical use: HA's
12265
+ * `binary_sensor` with `device_class: connectivity` for a remote
12266
+ * gateway or bridge). Push-driven.
12267
+ */
12268
+ var ConnectivityStatusSchema = object({
11367
12269
  /** True when the upstream system considers the entity connected. */
11368
12270
  connected: boolean(),
11369
12271
  /** Ms epoch of the last transition. 0 if never observed. */
11370
12272
  lastChangedAt: number()
11371
12273
  });
11372
- DeviceType.Sensor;
12274
+ var connectivityCapability = {
12275
+ name: "connectivity",
12276
+ scope: "device",
12277
+ deviceNative: true,
12278
+ mode: "singleton",
12279
+ deviceTypes: [DeviceType.Sensor],
12280
+ methods: {},
12281
+ status: {
12282
+ schema: ConnectivityStatusSchema,
12283
+ kind: "push"
12284
+ },
12285
+ runtimeState: ConnectivityStatusSchema
12286
+ };
12287
+ /**
12288
+ * Generic device-consumables capability — surfaces a device's
12289
+ * maintenance items (vacuum filters/brushes, replaceable cartridges,
12290
+ * descaling cycles, …) with their remaining life and an optional
12291
+ * "Replaced" reset action. Device-agnostic: any provider that knows its
12292
+ * device tracks consumables can register it; the cap declares no
12293
+ * vocabulary of its own — the provider names each item verbatim.
12294
+ *
12295
+ * Like `childLayout`, the cap is INERT until a provider sets items: no
12296
+ * provider populates it by guessing (no HA inference). The UI renders a
12297
+ * "No consumables reported" placeholder when `items` is empty.
12298
+ */
12299
+ /** A single consumable item. Either a continuous `level` (remaining
12300
+ * life %) or a discrete `status` may be known — both may be null when a
12301
+ * provider only knows the item exists. `level` and `status` are not
12302
+ * mutually exclusive; a provider may report both. */
12303
+ var ConsumableItemSchema = object({
12304
+ /** Stable id, e.g. 'main-brush'. */
12305
+ key: string().min(1),
12306
+ /** Display name. */
12307
+ label: string().min(1),
12308
+ /** Remaining life % when known (0..100). */
12309
+ level: number().min(0).max(100).nullable(),
12310
+ /** Discrete state when known (binary mode). */
12311
+ status: _enum(["ok", "replace"]).nullable(),
12312
+ /** Ms epoch of the last replace, when known. */
12313
+ lastResetAt: number().nullable(),
12314
+ /** Whether `reset()` is meaningful for this item. */
12315
+ resettable: boolean()
12316
+ });
11373
12317
  var ConsumablesStatusSchema = object({
11374
- items: array(object({
11375
- /** Stable id, e.g. 'main-brush'. */
11376
- key: string().min(1),
11377
- /** Display name. */
11378
- label: string().min(1),
11379
- /** Remaining life % when known (0..100). */
11380
- level: number().min(0).max(100).nullable(),
11381
- /** Discrete state when known (binary mode). */
11382
- status: _enum(["ok", "replace"]).nullable(),
11383
- /** Ms epoch of the last replace, when known. */
11384
- lastResetAt: number().nullable(),
11385
- /** Whether `reset()` is meaningful for this item. */
11386
- resettable: boolean()
11387
- })),
12318
+ items: array(ConsumableItemSchema),
11388
12319
  lastChangedAt: number()
11389
12320
  });
11390
- Object.values(DeviceType), method(object({
11391
- deviceId: number().int().nonnegative(),
11392
- key: string().min(1)
11393
- }), _void(), {
11394
- kind: "mutation",
11395
- auth: "admin"
11396
- }), ConsumablesStatusSchema.extend({ lastFetchedAt: number() });
11397
- object({
12321
+ var consumablesCapability = {
12322
+ name: "consumables",
12323
+ scope: "device",
12324
+ deviceNative: true,
12325
+ mode: "singleton",
12326
+ deviceTypes: Object.values(DeviceType),
12327
+ deviceConfig: { ui: {
12328
+ kind: "widget",
12329
+ widgetId: "host/consumables-panel",
12330
+ tab: "consumables",
12331
+ topTab: true,
12332
+ label: "Consumables",
12333
+ order: 5
12334
+ } },
12335
+ methods: {
12336
+ /** Mark a consumable as replaced — resets its remaining life. Only
12337
+ * meaningful when the item's `resettable` is true. */
12338
+ reset: method(object({
12339
+ deviceId: number().int().nonnegative(),
12340
+ key: string().min(1)
12341
+ }), _void(), {
12342
+ kind: "mutation",
12343
+ auth: "admin"
12344
+ }) },
12345
+ status: {
12346
+ schema: ConsumablesStatusSchema,
12347
+ kind: "push",
12348
+ empty: {
12349
+ items: [],
12350
+ lastChangedAt: 0
12351
+ },
12352
+ itemArray: {
12353
+ path: "items",
12354
+ keyField: "key",
12355
+ labelField: "label",
12356
+ itemSchema: ConsumableItemSchema,
12357
+ emptyItem: {
12358
+ key: "",
12359
+ label: "",
12360
+ level: null,
12361
+ status: null,
12362
+ lastResetAt: null,
12363
+ resettable: false
12364
+ }
12365
+ }
12366
+ },
12367
+ runtimeState: ConsumablesStatusSchema.extend({ lastFetchedAt: number() })
12368
+ };
12369
+ /**
12370
+ * Door / window / opening / garage / valve contact sensor. Boolean
12371
+ * "is the entry currently open" with the timestamp of the last
12372
+ * transition. Drives Home Assistant `binary_sensor` entries whose
12373
+ * `device_class` is `door`, `window`, `opening`, `garage`, or
12374
+ * `garage_door` — and any future native integration that needs
12375
+ * the same semantics.
12376
+ *
12377
+ * Push-driven: providers update the slice on transition events from
12378
+ * the upstream source (HA WebSocket `state_changed`, ZWave
12379
+ * `notification` …). Consumers read the slice; no polling.
12380
+ */
12381
+ var ContactStatusSchema = object({
11398
12382
  /** True when the entry is open; false when closed. */
11399
12383
  entryOpen: boolean(),
11400
12384
  /** Ms epoch of the last open↔closed transition. 0 if never observed. */
11401
12385
  lastChangedAt: number()
11402
12386
  });
11403
- DeviceType.Sensor;
11404
- object({
12387
+ var contactCapability = {
12388
+ name: "contact",
12389
+ scope: "device",
12390
+ deviceNative: true,
12391
+ mode: "singleton",
12392
+ deviceTypes: [DeviceType.Sensor],
12393
+ methods: {},
12394
+ status: {
12395
+ schema: ContactStatusSchema,
12396
+ kind: "push"
12397
+ },
12398
+ runtimeState: ContactStatusSchema
12399
+ };
12400
+ /**
12401
+ * Status slice — flat object (the framework's `runtimeState` contract
12402
+ * requires a `ZodObject`, not a discriminated union). The `kind`
12403
+ * field discriminates the value type at the type level via the
12404
+ * `ControlNumericValue` / `ControlStringValue` aliases; consumers
12405
+ * narrow with a kind check.
12406
+ *
12407
+ * The `value` field is typed as `number | string`:
12408
+ * - `kind === 'numeric'` → `value` is `number`
12409
+ * - `kind === 'select' | 'text' | 'datetime'` → `value` is `string`
12410
+ *
12411
+ * The `options` array is populated only when `kind === 'select'`
12412
+ * (empty array for the other kinds). It lives in the slice because
12413
+ * HA `select.*` entities may update their options at runtime.
12414
+ *
12415
+ * The `unit`/`min`/`max`/`step` fields are meaningful for
12416
+ * `kind === 'numeric'` and are absent (undefined) for all other
12417
+ * kinds. They are populated live from HA attributes on each state
12418
+ * push — consistent with `options` and the sensor-unit approach.
12419
+ */
12420
+ var ControlStatusSchema = object({
11405
12421
  kind: _enum([
11406
12422
  "numeric",
11407
12423
  "select",
@@ -11463,14 +12479,31 @@ var ControlSetValueInputSchema = discriminatedUnion("kind", [
11463
12479
  value: string().min(1)
11464
12480
  })
11465
12481
  ]);
11466
- DeviceType.Control, method(object({
11467
- deviceId: number().int().nonnegative(),
11468
- control: ControlSetValueInputSchema
11469
- }), _void(), {
11470
- kind: "mutation",
11471
- auth: "admin"
11472
- });
11473
- object({
12482
+ var controlCapability = {
12483
+ name: "control",
12484
+ scope: "device",
12485
+ deviceNative: true,
12486
+ mode: "singleton",
12487
+ deviceTypes: [DeviceType.Control],
12488
+ methods: { setValue: method(object({
12489
+ deviceId: number().int().nonnegative(),
12490
+ control: ControlSetValueInputSchema
12491
+ }), _void(), {
12492
+ kind: "mutation",
12493
+ auth: "admin"
12494
+ }) },
12495
+ status: {
12496
+ schema: ControlStatusSchema,
12497
+ kind: "push"
12498
+ },
12499
+ /**
12500
+ * Runtime-state slice — mirrored by the kernel. UI widgets (slider /
12501
+ * dropdown / text field / date picker) read the slice's discriminant
12502
+ * and value directly without polling the provider.
12503
+ */
12504
+ runtimeState: ControlStatusSchema
12505
+ };
12506
+ var CoverStatusSchema = object({
11474
12507
  /** Lifecycle state of the cover. */
11475
12508
  state: _enum([
11476
12509
  "open",
@@ -11487,28 +12520,50 @@ object({
11487
12520
  /** Ms epoch when the slice was last updated. */
11488
12521
  lastChangedAt: number()
11489
12522
  });
11490
- DeviceType.Cover, method(object({ deviceId: number().int().nonnegative() }), _void(), {
11491
- kind: "mutation",
11492
- auth: "admin"
11493
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11494
- kind: "mutation",
11495
- auth: "admin"
11496
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11497
- kind: "mutation",
11498
- auth: "admin"
11499
- }), method(object({
11500
- deviceId: number().int().nonnegative(),
11501
- position: number().min(0).max(100)
11502
- }), _void(), {
11503
- kind: "mutation",
11504
- auth: "admin"
11505
- }), method(object({
11506
- deviceId: number().int().nonnegative(),
11507
- tiltPosition: number().min(0).max(100)
11508
- }), _void(), {
11509
- kind: "mutation",
11510
- auth: "admin"
11511
- });
12523
+ var coverCapability = {
12524
+ name: "cover",
12525
+ scope: "device",
12526
+ deviceNative: true,
12527
+ mode: "singleton",
12528
+ deviceTypes: [DeviceType.Cover],
12529
+ methods: {
12530
+ open: method(object({ deviceId: number().int().nonnegative() }), _void(), {
12531
+ kind: "mutation",
12532
+ auth: "admin"
12533
+ }),
12534
+ close: method(object({ deviceId: number().int().nonnegative() }), _void(), {
12535
+ kind: "mutation",
12536
+ auth: "admin"
12537
+ }),
12538
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
12539
+ kind: "mutation",
12540
+ auth: "admin"
12541
+ }),
12542
+ setPosition: method(object({
12543
+ deviceId: number().int().nonnegative(),
12544
+ position: number().min(0).max(100)
12545
+ }), _void(), {
12546
+ kind: "mutation",
12547
+ auth: "admin"
12548
+ }),
12549
+ setTiltPosition: method(object({
12550
+ deviceId: number().int().nonnegative(),
12551
+ tiltPosition: number().min(0).max(100)
12552
+ }), _void(), {
12553
+ kind: "mutation",
12554
+ auth: "admin"
12555
+ })
12556
+ },
12557
+ status: {
12558
+ schema: CoverStatusSchema,
12559
+ kind: "push"
12560
+ },
12561
+ /**
12562
+ * Runtime-state slice — mirrored by the kernel. UI controls watch
12563
+ * the slice for live position changes during a move.
12564
+ */
12565
+ runtimeState: CoverStatusSchema
12566
+ };
11512
12567
  /**
11513
12568
  * Vendor-neutral day/night (IR-cut) control — the per-camera config cap
11514
12569
  * shared by reolink / hikvision / amcrest. Models the common firmware
@@ -11538,7 +12593,12 @@ var NormalizedRangeSchema$1 = object({
11538
12593
  max: number(),
11539
12594
  step: number()
11540
12595
  });
11541
- object({
12596
+ /**
12597
+ * Current day/night state. Optional fields are absent when the camera
12598
+ * does not expose that knob (a photocell-less model reports no
12599
+ * `sensitivity`). `lastFetchedAt` feeds the runtime-state bridge.
12600
+ */
12601
+ var DayNightStatusSchema = object({
11542
12602
  mode: DayNightModeSchema,
11543
12603
  /** IR-cut trigger sensitivity, NORMALIZED 0–100 (higher = switches to night sooner). */
11544
12604
  sensitivity: number().optional(),
@@ -11571,13 +12631,33 @@ var DayNightSettingsPatchSchema = object({
11571
12631
  sensitivity: number().optional(),
11572
12632
  switchDelaySec: number().optional()
11573
12633
  });
11574
- DeviceType.Camera, method(object({ deviceId: number() }), DayNightOptionsSchema), method(object({
11575
- deviceId: number(),
11576
- settings: DayNightSettingsPatchSchema
11577
- }), _void(), {
11578
- kind: "mutation",
11579
- auth: "admin"
11580
- });
12634
+ var dayNightCapability = {
12635
+ name: "day-night",
12636
+ scope: "device",
12637
+ deviceNative: true,
12638
+ mode: "singleton",
12639
+ deviceTypes: [DeviceType.Camera],
12640
+ deviceConfig: { ui: {
12641
+ kind: "derived-form",
12642
+ builderId: "day-night",
12643
+ tab: "image"
12644
+ } },
12645
+ methods: {
12646
+ getOptions: method(object({ deviceId: number() }), DayNightOptionsSchema),
12647
+ setSettings: method(object({
12648
+ deviceId: number(),
12649
+ settings: DayNightSettingsPatchSchema
12650
+ }), _void(), {
12651
+ kind: "mutation",
12652
+ auth: "admin"
12653
+ })
12654
+ },
12655
+ status: {
12656
+ schema: DayNightStatusSchema,
12657
+ kind: "poll"
12658
+ },
12659
+ runtimeState: DayNightStatusSchema
12660
+ };
11581
12661
  /**
11582
12662
  * Identity envelope for a device's upstream-system metadata.
11583
12663
  *
@@ -11621,6 +12701,66 @@ var SourceInfoSchema = object({
11621
12701
  raw: record(string(), unknown()).optional()
11622
12702
  });
11623
12703
  /**
12704
+ * Build the synthetic SourceInfo every existing provider falls back to
12705
+ * when no upstream value has been persisted. Keeps non-migrated providers
12706
+ * (Reolink / Hikvision / ONVIF / Frigate / RTSP) functional without code
12707
+ * changes — `id` reuses the CamStack stableId, `system` reuses the addon
12708
+ * id. Real upstream identity replaces this once a provider migrates and
12709
+ * calls `updateSourceInfo()` with concrete values.
12710
+ */
12711
+ function synthesizeSourceInfo(input) {
12712
+ return {
12713
+ id: input.stableId,
12714
+ system: input.addonId
12715
+ };
12716
+ }
12717
+ /**
12718
+ * Shallow-merge a partial patch over a prior SourceInfo. `undefined`
12719
+ * patch values are ignored (no field clobber); to clear a field
12720
+ * explicitly, providers omit it from the patch and rebuild from scratch
12721
+ * via `updateSourceInfo({})` cycles — uncommon enough that we don't
12722
+ * bother with a `null`-means-delete sentinel.
12723
+ *
12724
+ * The `raw` field is also shallow-merged: passing `raw: { foo: 1 }`
12725
+ * merges over existing `raw` keys instead of replacing the whole bag.
12726
+ */
12727
+ function mergeSourceInfo(prev, patch) {
12728
+ const next = { ...prev };
12729
+ for (const [k, v] of Object.entries(patch)) {
12730
+ if (v === void 0) continue;
12731
+ if (k === "raw" && typeof v === "object" && v !== null) {
12732
+ next.raw = {
12733
+ ...prev.raw,
12734
+ ...v
12735
+ };
12736
+ continue;
12737
+ }
12738
+ next[k] = v;
12739
+ }
12740
+ return next;
12741
+ }
12742
+ /**
12743
+ * Key under which `SourceInfo` lives inside `DeviceMeta.metadata`. The
12744
+ * blob is a free-form `Record<string, unknown>` shared with hardware-
12745
+ * identity fields (`manufacturer`, `model`, `firmware`, …); SourceInfo
12746
+ * lives under a single nested key to keep it composable with those.
12747
+ */
12748
+ var SOURCE_INFO_METADATA_KEY = "sourceInfo";
12749
+ /**
12750
+ * Extract a SourceInfo from a `DeviceMeta.metadata` blob, validating
12751
+ * with Zod. Returns `null` when the blob is missing or the nested
12752
+ * `sourceInfo` key fails validation — callers fall back to the
12753
+ * synthetic default in that case (see `synthesizeSourceInfo`).
12754
+ */
12755
+ function extractSourceInfoFromMetadata(metadata) {
12756
+ if (!metadata) return null;
12757
+ const raw = metadata[SOURCE_INFO_METADATA_KEY];
12758
+ if (!raw) return null;
12759
+ const parsed = SourceInfoSchema.safeParse(raw);
12760
+ if (!parsed.success) return null;
12761
+ return parsed.data;
12762
+ }
12763
+ /**
11624
12764
  * device-discovery — device-scoped capability for parents that host /
11625
12765
  * enumerate child devices (Reolink Hub / NVR, ONVIF gateway, future
11626
12766
  * integrations). The parent advertises a list of discoverable children,
@@ -11707,38 +12847,129 @@ var DeviceDiscoveryStatusSchema = object({
11707
12847
  /** Last error surfaced from the source (rendered as a banner). */
11708
12848
  lastError: string().nullable()
11709
12849
  });
11710
- 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(), {
11711
- kind: "mutation",
11712
- auth: "admin"
11713
- }), method(object({
11714
- deviceId: number().int().nonnegative(),
11715
- childNativeId: string(),
11716
- /** Optional override for the child's display name. */
11717
- name: string().optional()
11718
- }), object({
11719
- deviceId: number().int().nonnegative(),
11720
- stableId: string()
11721
- }), {
11722
- kind: "mutation",
11723
- auth: "admin"
11724
- }), method(object({
11725
- deviceId: number().int().nonnegative(),
11726
- childDeviceId: number().int().nonnegative()
11727
- }), _void(), {
11728
- kind: "mutation",
11729
- auth: "admin"
11730
- });
11731
- object({
12850
+ var deviceDiscoveryCapability = {
12851
+ name: "device-discovery",
12852
+ scope: "device",
12853
+ deviceNative: true,
12854
+ mode: "singleton",
12855
+ deviceTypes: [DeviceType.Hub],
12856
+ status: {
12857
+ schema: DeviceDiscoveryStatusSchema,
12858
+ kind: "poll"
12859
+ },
12860
+ runtimeState: DeviceDiscoveryStatusSchema.extend({ lastFetchedAt: number().int().nonnegative() }),
12861
+ methods: {
12862
+ /**
12863
+ * Snapshot of the current `discovered` list. Returns the
12864
+ * runtime-state cache — call `refreshDiscovery` first if a
12865
+ * fresh round-trip to the source is required.
12866
+ */
12867
+ listDiscovered: method(object({ deviceId: number().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly()),
12868
+ /**
12869
+ * Force the integration to re-enumerate and update the
12870
+ * runtime-state slice. Returns the freshly-enumerated list (also
12871
+ * available via `listDiscovered` post-call).
12872
+ */
12873
+ refreshDiscovery: method(object({ deviceId: number().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly(), {
12874
+ kind: "mutation",
12875
+ auth: "admin"
12876
+ }),
12877
+ /**
12878
+ * Promote a discovered entry to a real child device. The framework
12879
+ * creates the child via `kernel.devices.create()` with
12880
+ * `parentDeviceId = parent.id` and seeds the child's config from
12881
+ * `childInitialConfig` (driver-defined; usually carries channel +
12882
+ * uid + parent reference). Returns the kernel-assigned numeric id.
12883
+ */
12884
+ adoptDevice: method(object({
12885
+ deviceId: number().int().nonnegative(),
12886
+ childNativeId: string(),
12887
+ /** Optional override for the child's display name. */
12888
+ name: string().optional()
12889
+ }), object({
12890
+ deviceId: number().int().nonnegative(),
12891
+ stableId: string()
12892
+ }), {
12893
+ kind: "mutation",
12894
+ auth: "admin"
12895
+ }),
12896
+ /**
12897
+ * Inverse of `adoptDevice`: removes the child device from the
12898
+ * kernel registry. The discovered entry remains in the
12899
+ * enumeration (status updates resume) so the operator can re-adopt
12900
+ * it later without a fresh refresh.
12901
+ */
12902
+ releaseDevice: method(object({
12903
+ deviceId: number().int().nonnegative(),
12904
+ childDeviceId: number().int().nonnegative()
12905
+ }), _void(), {
12906
+ kind: "mutation",
12907
+ auth: "admin"
12908
+ })
12909
+ }
12910
+ };
12911
+ /**
12912
+ * Doorbell button cap. Two kinds of providers coexist behind this cap
12913
+ * name (same pattern as `snapshot`):
12914
+ *
12915
+ * - **Native** providers: registered per-device by device-driver
12916
+ * addons via `ctx.registerNativeCap` — either on a
12917
+ * `DeviceType.Button` accessory with `role: DeviceRole.Doorbell`,
12918
+ * or directly on the camera (Reolink registers at camera level).
12919
+ * Emits an `onPressed` event every time the firmware pushes a
12920
+ * ring; status tracks the last press and a pressCount since start
12921
+ * (diagnostic).
12922
+ *
12923
+ * - **Wrapper** provider: the `virtual-doorbell` system builtin
12924
+ * (`@camstack/system/builtins/doorbell`). Turns ANY binary-ish
12925
+ * device (contact / switch / event-emitter …) into a doorbell for
12926
+ * a camera. `defaultActive: false` — the operator explicitly binds
12927
+ * it per camera in the device-bindings UI, then picks the source
12928
+ * device + trigger in the per-device settings.
12929
+ *
12930
+ * The DeviceEventPropagator re-emits `onPressed` on the camera parent
12931
+ * — subscribers listening at the camera level receive ring events
12932
+ * with `via[]` populated. No code on the parent needed.
12933
+ */
12934
+ var DoorbellStatusSchema = object({
11732
12935
  /** Ms epoch of the last press. null = never observed since this provider started. */
11733
12936
  lastPressedAt: number().nullable(),
11734
12937
  /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
11735
12938
  pressCountSinceStart: number()
11736
12939
  });
11737
- object({
12940
+ var DoorbellPressEventSchema = object({
11738
12941
  deviceId: number(),
11739
12942
  timestamp: number()
11740
12943
  });
11741
- DeviceType.Button, DeviceType.Camera;
12944
+ var doorbellCapability = {
12945
+ name: "doorbell",
12946
+ scope: "device",
12947
+ deviceNative: true,
12948
+ mode: "singleton",
12949
+ kind: "wrapper",
12950
+ defaultActive: false,
12951
+ deviceTypes: [DeviceType.Button, DeviceType.Camera],
12952
+ exposesDeviceSettings: true,
12953
+ methods: {},
12954
+ events: {
12955
+ /**
12956
+ * Fires once per physical press. Reolink delivers via Baichuan
12957
+ * push (`ReolinkSimpleEvent.type === 'doorbell'`). There is no
12958
+ * release/duration — it's a pulse.
12959
+ */
12960
+ onPressed: { data: DoorbellPressEventSchema } },
12961
+ status: {
12962
+ schema: DoorbellStatusSchema,
12963
+ kind: "push"
12964
+ },
12965
+ /**
12966
+ * Runtime-state slice — last press timestamp + lifetime press count.
12967
+ * Mirrored by the kernel and readable via
12968
+ * `device.state.doorbell.value`. UIs can show "last ring 5m ago"
12969
+ * without subscribing.
12970
+ */
12971
+ runtimeState: DoorbellStatusSchema
12972
+ };
11742
12973
  /**
11743
12974
  * Enum-state sensor — a string value picked from a finite option set.
11744
12975
  * Drives HA `sensor` entries with `state_class: enum` (HVAC action
@@ -11754,7 +12985,7 @@ var EnumSensorDateTimeFormatSchema = _enum([
11754
12985
  "time",
11755
12986
  "datetime"
11756
12987
  ]);
11757
- object({
12988
+ var EnumSensorStatusSchema = object({
11758
12989
  value: string(),
11759
12990
  /**
11760
12991
  * Set for `DateTimeSensor`-role sensors so the UI renders the ISO `value`
@@ -11766,7 +12997,19 @@ object({
11766
12997
  /** Ms epoch when the slice was last updated. */
11767
12998
  lastFetchedAt: number()
11768
12999
  });
11769
- DeviceType.Sensor;
13000
+ var enumSensorCapability = {
13001
+ name: "enum-sensor",
13002
+ scope: "device",
13003
+ deviceNative: true,
13004
+ mode: "singleton",
13005
+ deviceTypes: [DeviceType.Sensor],
13006
+ methods: {},
13007
+ status: {
13008
+ schema: EnumSensorStatusSchema,
13009
+ kind: "push"
13010
+ },
13011
+ runtimeState: EnumSensorStatusSchema
13012
+ };
11770
13013
  /**
11771
13014
  * Generic stateless event emitter. Installed on a `DeviceType.EventEmitter`
11772
13015
  * device. Carries the device's EXACT declared event vocabulary verbatim
@@ -11784,12 +13027,25 @@ var EventFireSchema = object({
11784
13027
  timestamp: number(),
11785
13028
  seq: number()
11786
13029
  });
11787
- object({
13030
+ var EventEmitterStatusSchema = object({
11788
13031
  eventTypes: array(string()),
11789
13032
  lastEvent: EventFireSchema.nullable(),
11790
13033
  eventCountSinceStart: number()
11791
13034
  });
11792
- DeviceType.EventEmitter;
13035
+ var eventEmitterCapability = {
13036
+ name: "event-emitter",
13037
+ scope: "device",
13038
+ deviceNative: true,
13039
+ mode: "singleton",
13040
+ deviceTypes: [DeviceType.EventEmitter],
13041
+ methods: {},
13042
+ events: { onEvent: { data: EventFireSchema } },
13043
+ status: {
13044
+ schema: EventEmitterStatusSchema,
13045
+ kind: "push"
13046
+ },
13047
+ runtimeState: EventEmitterStatusSchema
13048
+ };
11793
13049
  /**
11794
13050
  * Fan-control cap. Models HA `fan.*` entity-specific surfaces:
11795
13051
  * speed percentage, preset modes, ceiling-fan direction, and
@@ -11809,7 +13065,7 @@ DeviceType.EventEmitter;
11809
13065
  * - `FanOscillating` — oscillation toggle
11810
13066
  */
11811
13067
  var FanDirectionSchema = _enum(["forward", "reverse"]);
11812
- object({
13068
+ var FanControlStatusSchema = object({
11813
13069
  /** Active speed as 0..100 inclusive. Null when the device has no
11814
13070
  * speed surface (single-speed fan). */
11815
13071
  percentage: number().min(0).max(100).nullable(),
@@ -11830,45 +13086,118 @@ object({
11830
13086
  /** Ms epoch when the slice was last updated. */
11831
13087
  lastChangedAt: number()
11832
13088
  });
11833
- DeviceType.Fan, method(object({
11834
- deviceId: number().int().nonnegative(),
11835
- percentage: number().min(0).max(100)
11836
- }), _void(), {
11837
- kind: "mutation",
11838
- auth: "admin"
11839
- }), method(object({
11840
- deviceId: number().int().nonnegative(),
11841
- preset: string().min(1)
11842
- }), _void(), {
11843
- kind: "mutation",
11844
- auth: "admin"
11845
- }), method(object({
11846
- deviceId: number().int().nonnegative(),
11847
- direction: FanDirectionSchema
11848
- }), _void(), {
11849
- kind: "mutation",
11850
- auth: "admin"
11851
- }), method(object({
11852
- deviceId: number().int().nonnegative(),
11853
- oscillating: boolean()
11854
- }), _void(), {
11855
- kind: "mutation",
11856
- auth: "admin"
11857
- });
11858
- object({
13089
+ var fanControlCapability = {
13090
+ name: "fan-control",
13091
+ scope: "device",
13092
+ deviceNative: true,
13093
+ mode: "singleton",
13094
+ deviceTypes: [DeviceType.Fan],
13095
+ methods: {
13096
+ setPercentage: method(object({
13097
+ deviceId: number().int().nonnegative(),
13098
+ percentage: number().min(0).max(100)
13099
+ }), _void(), {
13100
+ kind: "mutation",
13101
+ auth: "admin"
13102
+ }),
13103
+ setPreset: method(object({
13104
+ deviceId: number().int().nonnegative(),
13105
+ preset: string().min(1)
13106
+ }), _void(), {
13107
+ kind: "mutation",
13108
+ auth: "admin"
13109
+ }),
13110
+ setDirection: method(object({
13111
+ deviceId: number().int().nonnegative(),
13112
+ direction: FanDirectionSchema
13113
+ }), _void(), {
13114
+ kind: "mutation",
13115
+ auth: "admin"
13116
+ }),
13117
+ setOscillating: method(object({
13118
+ deviceId: number().int().nonnegative(),
13119
+ oscillating: boolean()
13120
+ }), _void(), {
13121
+ kind: "mutation",
13122
+ auth: "admin"
13123
+ })
13124
+ },
13125
+ status: {
13126
+ schema: FanControlStatusSchema,
13127
+ kind: "push"
13128
+ },
13129
+ /**
13130
+ * Runtime-state slice — mirrored by the kernel. UI fan speed
13131
+ * sliders read `percentage` for live updates.
13132
+ */
13133
+ runtimeState: FanControlStatusSchema
13134
+ };
13135
+ /**
13136
+ * Water leak / moisture sensor. Boolean "is liquid currently
13137
+ * detected" with the timestamp of the last transition. Drives Home
13138
+ * Assistant `binary_sensor` entries with `device_class: moisture`,
13139
+ * and any future native flood sensor.
13140
+ *
13141
+ * Push-driven from the upstream source.
13142
+ */
13143
+ var FloodStatusSchema = object({
11859
13144
  /** True when leak is currently detected. */
11860
13145
  flooded: boolean(),
11861
13146
  /** Ms epoch of the last flooded↔dry transition. 0 if never observed. */
11862
13147
  lastChangedAt: number()
11863
13148
  });
11864
- DeviceType.Sensor;
11865
- object({
13149
+ var floodCapability = {
13150
+ name: "flood",
13151
+ scope: "device",
13152
+ deviceNative: true,
13153
+ mode: "singleton",
13154
+ deviceTypes: [DeviceType.Sensor],
13155
+ methods: {},
13156
+ status: {
13157
+ schema: FloodStatusSchema,
13158
+ kind: "push"
13159
+ },
13160
+ runtimeState: FloodStatusSchema
13161
+ };
13162
+ /**
13163
+ * Combustible-gas (LPG / methane / hydrogen) alarm sensor. Drives
13164
+ * Home Assistant `binary_sensor` entries with `device_class: gas`.
13165
+ * Push-driven.
13166
+ */
13167
+ var GasStatusSchema = object({
11866
13168
  detected: boolean(),
11867
13169
  /** Ms epoch of the last transition. 0 if never observed. */
11868
13170
  lastChangedAt: number()
11869
13171
  });
11870
- DeviceType.Sensor;
11871
- object({
13172
+ var gasCapability = {
13173
+ name: "gas",
13174
+ scope: "device",
13175
+ deviceNative: true,
13176
+ mode: "singleton",
13177
+ deviceTypes: [DeviceType.Sensor],
13178
+ methods: {},
13179
+ status: {
13180
+ schema: GasStatusSchema,
13181
+ kind: "push"
13182
+ },
13183
+ runtimeState: GasStatusSchema
13184
+ };
13185
+ /**
13186
+ * Humidifier / dehumidifier cap. Models HA `humidifier.*` entities —
13187
+ * an on/off actuator with an optional target-humidity setpoint and an
13188
+ * optional vendor mode (`auto` / `normal` / `baby` / …).
13189
+ *
13190
+ * A climate-family sibling: the slice carries the on/off state, the
13191
+ * current + target relative humidity (0..100), the active mode and its
13192
+ * available set, plus HA's free-form `action` readout
13193
+ * (`humidifying` / `drying` / `idle` / `off`). `minHumidity` /
13194
+ * `maxHumidity` mirror HA's `min_humidity` / `max_humidity` attributes
13195
+ * (null → the UI falls back to a 0..100 range).
13196
+ *
13197
+ * Providers populate only what the hardware reports — `mode` and the
13198
+ * humidity fields stay null when the device has no such surface.
13199
+ */
13200
+ var HumidifierStatusSchema = object({
11872
13201
  /** Whether the humidifier is currently on. */
11873
13202
  on: boolean(),
11874
13203
  /** Current measured relative humidity (0..100). Null when not reported. */
@@ -11890,26 +13219,54 @@ object({
11890
13219
  /** Ms epoch when the slice was last updated. */
11891
13220
  lastChangedAt: number()
11892
13221
  });
11893
- DeviceType.Humidifier, method(object({
11894
- deviceId: number().int().nonnegative(),
11895
- on: boolean()
11896
- }), _void(), {
11897
- kind: "mutation",
11898
- auth: "admin"
11899
- }), method(object({
11900
- deviceId: number().int().nonnegative(),
11901
- humidity: number().min(0).max(100)
11902
- }), _void(), {
11903
- kind: "mutation",
11904
- auth: "admin"
11905
- }), method(object({
11906
- deviceId: number().int().nonnegative(),
11907
- mode: string().min(1)
11908
- }), _void(), {
11909
- kind: "mutation",
11910
- auth: "admin"
11911
- });
11912
- object({
13222
+ var humidifierCapability = {
13223
+ name: "humidifier",
13224
+ scope: "device",
13225
+ deviceNative: true,
13226
+ mode: "singleton",
13227
+ deviceTypes: [DeviceType.Humidifier],
13228
+ methods: {
13229
+ setOn: method(object({
13230
+ deviceId: number().int().nonnegative(),
13231
+ on: boolean()
13232
+ }), _void(), {
13233
+ kind: "mutation",
13234
+ auth: "admin"
13235
+ }),
13236
+ setTargetHumidity: method(object({
13237
+ deviceId: number().int().nonnegative(),
13238
+ humidity: number().min(0).max(100)
13239
+ }), _void(), {
13240
+ kind: "mutation",
13241
+ auth: "admin"
13242
+ }),
13243
+ setMode: method(object({
13244
+ deviceId: number().int().nonnegative(),
13245
+ mode: string().min(1)
13246
+ }), _void(), {
13247
+ kind: "mutation",
13248
+ auth: "admin"
13249
+ })
13250
+ },
13251
+ status: {
13252
+ schema: HumidifierStatusSchema,
13253
+ kind: "push"
13254
+ },
13255
+ /**
13256
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
13257
+ * slice for live humidity / mode changes.
13258
+ */
13259
+ runtimeState: HumidifierStatusSchema
13260
+ };
13261
+ /**
13262
+ * Single-metric humidity reading. Drives Home Assistant `sensor`
13263
+ * entries with `device_class: humidity`.
13264
+ *
13265
+ * Unit normalisation: percent. The canonical display unit (`%`) is a
13266
+ * descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
13267
+ * `sourceInfo`.
13268
+ */
13269
+ var HumiditySensorStatusSchema = object({
11913
13270
  /** Current relative humidity, 0..100. */
11914
13271
  percent: number().min(0).max(100),
11915
13272
  /** Ms epoch when the slice was last updated. */
@@ -11924,15 +13281,57 @@ object({
11924
13281
  * auto-formatting when absent. */
11925
13282
  precision: number().int().min(0).max(10).optional()
11926
13283
  });
11927
- DeviceType.Sensor, DeviceType.Thermostat;
11928
- object({
13284
+ var humiditySensorCapability = {
13285
+ name: "humidity-sensor",
13286
+ scope: "device",
13287
+ deviceNative: true,
13288
+ mode: "singleton",
13289
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat],
13290
+ methods: {},
13291
+ status: {
13292
+ schema: HumiditySensorStatusSchema,
13293
+ kind: "push"
13294
+ },
13295
+ runtimeState: HumiditySensorStatusSchema
13296
+ };
13297
+ /**
13298
+ * Image display cap. Models a single still image exposed by an integration —
13299
+ * a snapshot, a chart, a generated picture, or a robot's cleaning-map render.
13300
+ *
13301
+ * Read-only: there are no setters. The provider resolves whatever upstream
13302
+ * source it has into an ABSOLUTE URL the browser loads directly:
13303
+ * - HA `image.*` entities → the `entity_picture` signed-token path
13304
+ * (token stays in the query string, so no auth header is needed);
13305
+ * - a Dreame/robot map → the cloud/OSS map-image URL (or an addon
13306
+ * data-plane URL serving the rendered map bytes), exposed as its own
13307
+ * Image child device grouped under the robot's container.
13308
+ * The slice carries that URL plus the upstream last-updated timestamp; the
13309
+ * image changes when the source's last-updated marker changes.
13310
+ */
13311
+ var ImageStatusSchema = object({
11929
13312
  /** Absolute signed URL the browser loads directly. Null when the
11930
13313
  * entity exposes no `entity_picture` (yet). */
11931
13314
  url: string().nullable(),
11932
13315
  /** Ms epoch of the upstream last-updated timestamp. Null at cold-start. */
11933
13316
  lastUpdated: number().nullable()
11934
13317
  });
11935
- DeviceType.Image;
13318
+ var imageCapability = {
13319
+ name: "image",
13320
+ scope: "device",
13321
+ deviceNative: true,
13322
+ mode: "singleton",
13323
+ deviceTypes: [DeviceType.Image],
13324
+ methods: {},
13325
+ status: {
13326
+ schema: ImageStatusSchema,
13327
+ kind: "push"
13328
+ },
13329
+ /**
13330
+ * Runtime-state slice — mirrored by the kernel. The UI reads `url`
13331
+ * directly and renders the still image.
13332
+ */
13333
+ runtimeState: ImageStatusSchema
13334
+ };
11936
13335
  /**
11937
13336
  * Vendor-neutral image / picture-adjustment cap — the per-camera config
11938
13337
  * cap shared by reolink / hikvision / amcrest. Models the common ISP
@@ -11984,7 +13383,12 @@ var NormalizedRangeSchema = object({
11984
13383
  max: number(),
11985
13384
  step: number()
11986
13385
  });
11987
- object({
13386
+ /**
13387
+ * Current image-adjustment state. Every field optional — absent when the
13388
+ * camera does not expose that control. Slider values are NORMALIZED 0–100.
13389
+ * `lastFetchedAt` feeds the runtime-state bridge.
13390
+ */
13391
+ var ImageSettingsStatusSchema = object({
11988
13392
  /** Normalized 0–100. */
11989
13393
  brightness: number().optional(),
11990
13394
  /** Normalized 0–100. */
@@ -12050,13 +13454,33 @@ var ImageSettingsPatchSchema = object({
12050
13454
  exposureMode: ExposureModeSchema.optional(),
12051
13455
  backlightMode: BacklightModeSchema.optional()
12052
13456
  });
12053
- DeviceType.Camera, method(object({ deviceId: number() }), ImageSettingsOptionsSchema), method(object({
12054
- deviceId: number(),
12055
- settings: ImageSettingsPatchSchema
12056
- }), _void(), {
12057
- kind: "mutation",
12058
- auth: "admin"
12059
- });
13457
+ var imageSettingsCapability = {
13458
+ name: "image-settings",
13459
+ scope: "device",
13460
+ deviceNative: true,
13461
+ mode: "singleton",
13462
+ deviceTypes: [DeviceType.Camera],
13463
+ deviceConfig: { ui: {
13464
+ kind: "derived-form",
13465
+ builderId: "image-settings",
13466
+ tab: "image"
13467
+ } },
13468
+ methods: {
13469
+ getOptions: method(object({ deviceId: number() }), ImageSettingsOptionsSchema),
13470
+ setSettings: method(object({
13471
+ deviceId: number(),
13472
+ settings: ImageSettingsPatchSchema
13473
+ }), _void(), {
13474
+ kind: "mutation",
13475
+ auth: "admin"
13476
+ })
13477
+ },
13478
+ status: {
13479
+ schema: ImageSettingsStatusSchema,
13480
+ kind: "poll"
13481
+ },
13482
+ runtimeState: ImageSettingsStatusSchema
13483
+ };
12060
13484
  /**
12061
13485
  * Robotic lawn-mower cap. Models HA `lawn_mower.*` entities — anything
12062
13486
  * with a mowing lifecycle plus a dock action.
@@ -12081,7 +13505,7 @@ var DeviceCodeSeveritySchema = _enum([
12081
13505
  "warning",
12082
13506
  "error"
12083
13507
  ]);
12084
- object({
13508
+ var LawnMowerControlStatusSchema = object({
12085
13509
  /** Lifecycle activity of the mower. */
12086
13510
  activity: LawnMowerActivitySchema,
12087
13511
  /** 0..100 battery percentage. Null when the device has no battery
@@ -12101,17 +13525,37 @@ object({
12101
13525
  /** Ms epoch when the slice was last updated. */
12102
13526
  lastChangedAt: number()
12103
13527
  });
12104
- DeviceType.LawnMower, method(object({ deviceId: number().int().nonnegative() }), _void(), {
12105
- kind: "mutation",
12106
- auth: "admin"
12107
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12108
- kind: "mutation",
12109
- auth: "admin"
12110
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12111
- kind: "mutation",
12112
- auth: "admin"
12113
- });
12114
- object({
13528
+ var lawnMowerControlCapability = {
13529
+ name: "lawn-mower-control",
13530
+ scope: "device",
13531
+ deviceNative: true,
13532
+ mode: "singleton",
13533
+ deviceTypes: [DeviceType.LawnMower],
13534
+ methods: {
13535
+ startMowing: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13536
+ kind: "mutation",
13537
+ auth: "admin"
13538
+ }),
13539
+ pause: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13540
+ kind: "mutation",
13541
+ auth: "admin"
13542
+ }),
13543
+ dock: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13544
+ kind: "mutation",
13545
+ auth: "admin"
13546
+ })
13547
+ },
13548
+ status: {
13549
+ schema: LawnMowerControlStatusSchema,
13550
+ kind: "push"
13551
+ },
13552
+ /**
13553
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
13554
+ * slice for live activity + battery changes.
13555
+ */
13556
+ runtimeState: LawnMowerControlStatusSchema
13557
+ };
13558
+ var LockControlStatusSchema = object({
12115
13559
  /** Lifecycle state of the lock. `jammed` means the motor reported
12116
13560
  * failure to reach the target — operator intervention required. */
12117
13561
  state: _enum([
@@ -12124,24 +13568,45 @@ object({
12124
13568
  /** Ms epoch when the slice was last updated. */
12125
13569
  lastChangedAt: number()
12126
13570
  });
12127
- DeviceType.Lock, method(object({
12128
- deviceId: number().int().nonnegative(),
12129
- /** Optional PIN code required by some keypad locks. NOT
12130
- * persisted — passed directly to the upstream service. */
12131
- code: string().min(1).optional()
12132
- }), _void(), {
12133
- kind: "mutation",
12134
- auth: "admin"
12135
- }), method(object({
12136
- deviceId: number().int().nonnegative(),
12137
- code: string().min(1).optional()
12138
- }), _void(), {
12139
- kind: "mutation",
12140
- auth: "admin"
12141
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12142
- kind: "mutation",
12143
- auth: "admin"
12144
- });
13571
+ var lockControlCapability = {
13572
+ name: "lock-control",
13573
+ scope: "device",
13574
+ deviceNative: true,
13575
+ mode: "singleton",
13576
+ deviceTypes: [DeviceType.Lock],
13577
+ methods: {
13578
+ lock: method(object({
13579
+ deviceId: number().int().nonnegative(),
13580
+ /** Optional PIN code required by some keypad locks. NOT
13581
+ * persisted — passed directly to the upstream service. */
13582
+ code: string().min(1).optional()
13583
+ }), _void(), {
13584
+ kind: "mutation",
13585
+ auth: "admin"
13586
+ }),
13587
+ unlock: method(object({
13588
+ deviceId: number().int().nonnegative(),
13589
+ code: string().min(1).optional()
13590
+ }), _void(), {
13591
+ kind: "mutation",
13592
+ auth: "admin"
13593
+ }),
13594
+ open: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13595
+ kind: "mutation",
13596
+ auth: "admin"
13597
+ })
13598
+ },
13599
+ status: {
13600
+ schema: LockControlStatusSchema,
13601
+ kind: "push"
13602
+ },
13603
+ /**
13604
+ * Runtime-state slice — mirrored by the kernel. UI lock buttons
13605
+ * read `state` and disable themselves during `locking`/`unlocking`
13606
+ * transitions.
13607
+ */
13608
+ runtimeState: LockControlStatusSchema
13609
+ };
12145
13610
  /**
12146
13611
  * Media-player cap. Models HA `media_player.*` (Sonos, Chromecast,
12147
13612
  * Apple TV, Spotify, Roku, generic OTT receivers, …) on
@@ -12181,7 +13646,7 @@ var MediaInfoSchema = object({
12181
13646
  /** Optional cover-art / thumbnail URL. */
12182
13647
  imageUrl: string().optional()
12183
13648
  });
12184
- object({
13649
+ var MediaPlayerStatusSchema = object({
12185
13650
  /** Playback lifecycle state. */
12186
13651
  state: MediaPlayerStateSchema,
12187
13652
  /** Volume as 0..100 inclusive. Null when the device has no volume
@@ -12212,68 +13677,98 @@ object({
12212
13677
  /** Ms epoch when the slice was last updated. */
12213
13678
  lastChangedAt: number()
12214
13679
  });
12215
- DeviceType.MediaPlayer, method(object({ deviceId: number().int().nonnegative() }), _void(), {
12216
- kind: "mutation",
12217
- auth: "admin"
12218
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12219
- kind: "mutation",
12220
- auth: "admin"
12221
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12222
- kind: "mutation",
12223
- auth: "admin"
12224
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12225
- kind: "mutation",
12226
- auth: "admin"
12227
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
12228
- kind: "mutation",
12229
- auth: "admin"
12230
- }), method(object({
12231
- deviceId: number().int().nonnegative(),
12232
- positionMs: number().int().nonnegative()
12233
- }), _void(), {
12234
- kind: "mutation",
12235
- auth: "admin"
12236
- }), method(object({
12237
- deviceId: number().int().nonnegative(),
12238
- volumeLevel: number().min(0).max(100)
12239
- }), _void(), {
12240
- kind: "mutation",
12241
- auth: "admin"
12242
- }), method(object({
12243
- deviceId: number().int().nonnegative(),
12244
- muted: boolean()
12245
- }), _void(), {
12246
- kind: "mutation",
12247
- auth: "admin"
12248
- }), method(object({
12249
- deviceId: number().int().nonnegative(),
12250
- shuffle: boolean()
12251
- }), _void(), {
12252
- kind: "mutation",
12253
- auth: "admin"
12254
- }), method(object({
12255
- deviceId: number().int().nonnegative(),
12256
- repeat: MediaPlayerRepeatSchema
12257
- }), _void(), {
12258
- kind: "mutation",
12259
- auth: "admin"
12260
- }), method(object({
12261
- deviceId: number().int().nonnegative(),
12262
- source: string().min(1)
12263
- }), _void(), {
12264
- kind: "mutation",
12265
- auth: "admin"
12266
- }), method(object({
12267
- deviceId: number().int().nonnegative(),
12268
- /** Media identifier / URL. */
12269
- mediaId: string().min(1),
12270
- /** Media kind (`music`, `tvshow`, `movie`, `app`, …) — provider
12271
- * passes it through to the upstream service. */
12272
- mediaType: string().min(1)
12273
- }), _void(), {
12274
- kind: "mutation",
12275
- auth: "admin"
12276
- });
13680
+ var mediaPlayerCapability = {
13681
+ name: "media-player",
13682
+ scope: "device",
13683
+ deviceNative: true,
13684
+ mode: "singleton",
13685
+ deviceTypes: [DeviceType.MediaPlayer],
13686
+ methods: {
13687
+ play: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13688
+ kind: "mutation",
13689
+ auth: "admin"
13690
+ }),
13691
+ pause: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13692
+ kind: "mutation",
13693
+ auth: "admin"
13694
+ }),
13695
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13696
+ kind: "mutation",
13697
+ auth: "admin"
13698
+ }),
13699
+ next: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13700
+ kind: "mutation",
13701
+ auth: "admin"
13702
+ }),
13703
+ previous: method(object({ deviceId: number().int().nonnegative() }), _void(), {
13704
+ kind: "mutation",
13705
+ auth: "admin"
13706
+ }),
13707
+ seek: method(object({
13708
+ deviceId: number().int().nonnegative(),
13709
+ positionMs: number().int().nonnegative()
13710
+ }), _void(), {
13711
+ kind: "mutation",
13712
+ auth: "admin"
13713
+ }),
13714
+ setVolume: method(object({
13715
+ deviceId: number().int().nonnegative(),
13716
+ volumeLevel: number().min(0).max(100)
13717
+ }), _void(), {
13718
+ kind: "mutation",
13719
+ auth: "admin"
13720
+ }),
13721
+ setMute: method(object({
13722
+ deviceId: number().int().nonnegative(),
13723
+ muted: boolean()
13724
+ }), _void(), {
13725
+ kind: "mutation",
13726
+ auth: "admin"
13727
+ }),
13728
+ setShuffle: method(object({
13729
+ deviceId: number().int().nonnegative(),
13730
+ shuffle: boolean()
13731
+ }), _void(), {
13732
+ kind: "mutation",
13733
+ auth: "admin"
13734
+ }),
13735
+ setRepeat: method(object({
13736
+ deviceId: number().int().nonnegative(),
13737
+ repeat: MediaPlayerRepeatSchema
13738
+ }), _void(), {
13739
+ kind: "mutation",
13740
+ auth: "admin"
13741
+ }),
13742
+ selectSource: method(object({
13743
+ deviceId: number().int().nonnegative(),
13744
+ source: string().min(1)
13745
+ }), _void(), {
13746
+ kind: "mutation",
13747
+ auth: "admin"
13748
+ }),
13749
+ playMedia: method(object({
13750
+ deviceId: number().int().nonnegative(),
13751
+ /** Media identifier / URL. */
13752
+ mediaId: string().min(1),
13753
+ /** Media kind (`music`, `tvshow`, `movie`, `app`, …) — provider
13754
+ * passes it through to the upstream service. */
13755
+ mediaType: string().min(1)
13756
+ }), _void(), {
13757
+ kind: "mutation",
13758
+ auth: "admin"
13759
+ })
13760
+ },
13761
+ status: {
13762
+ schema: MediaPlayerStatusSchema,
13763
+ kind: "push"
13764
+ },
13765
+ /**
13766
+ * Runtime-state slice — mirrored by the kernel. UI players read the
13767
+ * full slice for live now-playing, volume, and progress updates
13768
+ * without polling.
13769
+ */
13770
+ runtimeState: MediaPlayerStatusSchema
13771
+ };
12277
13772
  /** Shared Zod schemas used across detection capabilities. */
12278
13773
  /**
12279
13774
  * Canonical frame-format enum mirrored on `FrameFormat` in
@@ -12825,33 +14320,66 @@ var PolygonPointSchema = object({
12825
14320
  });
12826
14321
  /** A camera detection zone — pure geometry/identity. */
12827
14322
  var ZoneSchema = object({
12828
- id: string(),
12829
- name: string(),
12830
- kind: ZoneKindEnum.default("polygon"),
12831
- /** Polygon vertices, fraction of frame (0–1). */
12832
- polygon: array(PolygonPointSchema).readonly(),
12833
- /** Visual color for UI rendering. */
12834
- color: string().default("#3b82f6")
12835
- });
12836
- DeviceType.Camera, method(object({ deviceId: number() }), array(ZoneSchema).readonly()), method(object({
12837
- deviceId: number(),
12838
- zone: ZoneSchema
12839
- }), _void(), {
12840
- kind: "mutation",
12841
- auth: "admin"
12842
- }), method(object({
12843
- deviceId: number(),
12844
- zoneId: string()
12845
- }), _void(), {
12846
- kind: "mutation",
12847
- auth: "admin"
12848
- }), method(object({
12849
- deviceId: number(),
12850
- zone: ZoneSchema
12851
- }), _void(), {
12852
- kind: "mutation",
12853
- auth: "admin"
12854
- }), object({ zones: array(ZoneSchema).readonly() });
14323
+ id: string(),
14324
+ name: string(),
14325
+ kind: ZoneKindEnum.default("polygon"),
14326
+ /** Polygon vertices, fraction of frame (0–1). */
14327
+ polygon: array(PolygonPointSchema).readonly(),
14328
+ /** Visual color for UI rendering. */
14329
+ color: string().default("#3b82f6")
14330
+ });
14331
+ /**
14332
+ * Zones capability — per-camera CRUD over polygon detection zones.
14333
+ *
14334
+ * Provider lives in `addon-pipeline-orchestrator` (hub-only). Persists
14335
+ * to per-device settings and mirrors into the `zones` device-state
14336
+ * slice on every mutation, so downstream consumers can subscribe via
14337
+ * `dev.state.zones.onChanged`.
14338
+ *
14339
+ * The cap surface only handles geometry + identity; filtering
14340
+ * behaviour (per-class, include/exclude, threshold) lives in the
14341
+ * consumer addons' rule arrays — see `ZoneRuleSchema` exported from
14342
+ * `capabilities/schemas/zone-rule.js`.
14343
+ */
14344
+ var zonesCapability = {
14345
+ name: "zones",
14346
+ scope: "device",
14347
+ mode: "singleton",
14348
+ deviceTypes: [DeviceType.Camera],
14349
+ methods: {
14350
+ listZones: method(object({ deviceId: number() }), array(ZoneSchema).readonly()),
14351
+ addZone: method(object({
14352
+ deviceId: number(),
14353
+ zone: ZoneSchema
14354
+ }), _void(), {
14355
+ kind: "mutation",
14356
+ auth: "admin"
14357
+ }),
14358
+ removeZone: method(object({
14359
+ deviceId: number(),
14360
+ zoneId: string()
14361
+ }), _void(), {
14362
+ kind: "mutation",
14363
+ auth: "admin"
14364
+ }),
14365
+ updateZone: method(object({
14366
+ deviceId: number(),
14367
+ zone: ZoneSchema
14368
+ }), _void(), {
14369
+ kind: "mutation",
14370
+ auth: "admin"
14371
+ })
14372
+ },
14373
+ /**
14374
+ * Runtime-state slice — the live zone catalogue mirrored by the
14375
+ * orchestrator on every CRUD mutation. Consumers read via
14376
+ * `device.state.zones.value` / `.watch(...)` without round-tripping
14377
+ * the cap, and the codegen DeviceProxy auto-wires the reactive
14378
+ * handle. Slice shape is `{ zones: Zone[] }` so future extensions
14379
+ * (e.g. zone groupings) can sit alongside the polygon list.
14380
+ */
14381
+ runtimeState: object({ zones: array(ZoneSchema).readonly() })
14382
+ };
12855
14383
  /**
12856
14384
  * A bounding box in NORMALIZED [0,1] frame coordinates for `getNativeCrop`. The
12857
14385
  * decode worker resolves it against the RETAINED native frame's real pixel dims,
@@ -13267,7 +14795,19 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
13267
14795
  parent: DetailParentSchema,
13268
14796
  steps: array(string()).optional()
13269
14797
  }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
13270
- object({
14798
+ /**
14799
+ * Hardware / firmware motion sensor cap — binary detected state plus
14800
+ * a timestamp of the last observation. Distinct from
14801
+ * `motion-detection.cap.ts` which owns the LOCAL ML motion pipeline;
14802
+ * `motion` is the lightweight readout from on-camera motion (Reolink
14803
+ * `GetMdState`, Baichuan push `type: motion`, ONVIF analytics).
14804
+ *
14805
+ * Native-motion providers also fan out to `detection.camera-native`
14806
+ * with `source: 'onboard'` so cross-cutting system services
14807
+ * (alert-center, advanced-notifier) can subscribe once and receive
14808
+ * motion from every camera.
14809
+ */
14810
+ var MotionStatusSchema = object({
13271
14811
  detected: boolean(),
13272
14812
  /** Ms epoch of the last detected-true observation. Null if never detected. */
13273
14813
  lastDetectedAt: number().nullable(),
@@ -13278,32 +14818,137 @@ object({
13278
14818
  */
13279
14819
  autoClearAfterMs: number().nullable()
13280
14820
  });
13281
- object({
14821
+ /**
14822
+ * Payload of `motion.onMotionChanged` event + the corresponding bus
14823
+ * event `EventCategory.MotionOnMotionChanged`. Single source of truth
14824
+ * — both the cap event surface and the bus payload type alias to this
14825
+ * schema.
14826
+ */
14827
+ var MotionOnMotionChangedDataSchema = object({
13282
14828
  deviceId: number(),
13283
14829
  detected: boolean(),
13284
14830
  timestamp: number(),
13285
14831
  source: MotionSourceEnum,
13286
14832
  regions: array(MotionRegionSchema).readonly().optional()
13287
14833
  });
13288
- DeviceType.Camera, DeviceType.Sensor, method(object({ deviceId: number() }), boolean());
13289
- object({
14834
+ var motionCapability = {
14835
+ name: "motion",
14836
+ scope: "device",
14837
+ mode: "singleton",
14838
+ /**
14839
+ * Providers register per-device natives via `ctx.registerNativeCap`
14840
+ * (Hikvision/Reolink/Amcrest/Wyze/HA/Homematic/Alexa/Matter) — there is
14841
+ * NO system singleton provider. Without this flag `resolveCapMount`
14842
+ * derived `{ kind: 'singleton' }`, so `motion.getStatus`/`isDetected`
14843
+ * resolved via `registry.getSingleton('motion')` (always null) and every
14844
+ * call 412'd "provider not available" while bindings listed a live
14845
+ * `motion` native (2026-08-02). The flag routes the router through
14846
+ * `requireDeviceScoped` → `getProviderForDevice`, like `motion-trigger`,
14847
+ * `snapshot` and every other per-device native cap.
14848
+ */
14849
+ deviceNative: true,
14850
+ deviceTypes: [DeviceType.Camera, DeviceType.Sensor],
14851
+ methods: {
14852
+ /**
14853
+ * Pull the current motion state synchronously. Convenience shortcut
14854
+ * for consumers that don't need the full status object; equivalent
14855
+ * to `getStatus()?.detected ?? false`. Will likely be folded into
14856
+ * `getStatus` once the auto-injected status surface lands in every
14857
+ * consumer.
14858
+ */
14859
+ isDetected: method(object({ deviceId: number() }), boolean()) },
14860
+ events: {
14861
+ /**
14862
+ * Fires every time the runner transitions a camera between
14863
+ * `watching` and `active` phases. `source` carries which motion
14864
+ * path drove the transition; `regions` is populated only for
14865
+ * `source: 'analyzer'` (frame-diff regions from the ML motion
14866
+ * detector) — onboard sources don't carry per-frame regions
14867
+ * here (camera-provided zones / AI metadata live in dedicated
14868
+ * channels: `detection.camera-native`, future zone capability).
14869
+ *
14870
+ * Consumers that want all motion pushes (even with `detected`
14871
+ * unchanged) should subscribe to the status subscription via
14872
+ * `device-manager.subscribeDeviceStatusAggregate` instead.
14873
+ */
14874
+ onMotionChanged: { data: MotionOnMotionChangedDataSchema } },
14875
+ status: {
14876
+ schema: MotionStatusSchema,
14877
+ kind: "push"
14878
+ },
14879
+ /**
14880
+ * Runtime-state slice — the last observed motion snapshot, mirrored
14881
+ * by the kernel and readable cross-process via
14882
+ * `device.state.motion.value`. Reads never invoke the provider, so
14883
+ * UIs and other addons can poll the cached state safely.
14884
+ */
14885
+ runtimeState: MotionStatusSchema
14886
+ };
14887
+ /**
14888
+ * Motion-trigger toggle for accessory devices.
14889
+ *
14890
+ * "Motion trigger" means: when the parent camera detects motion, the
14891
+ * accessory activates automatically. The cap exposes a single boolean
14892
+ * — `enabled` — that drivers map to the vendor-specific firmware
14893
+ * action (Reolink: `setSirenOnMotion`, `setFloodlightOnMotion`; ONVIF
14894
+ * relay: schedule binding; …).
14895
+ *
14896
+ * The accessory still has its own `switch` cap for direct on/off; this
14897
+ * cap is independent. Toggling motion-trigger on does NOT necessarily
14898
+ * toggle the switch on — it just instructs the firmware to flip the
14899
+ * switch when motion fires.
14900
+ *
14901
+ * Driver-specific knobs (motion duration window, brightness while
14902
+ * triggered, schedule windows) live in the device's
14903
+ * `getSettingsUISchema()` instead of bloating this cap — same
14904
+ * principle as `switch` and `brightness` keeping their surface
14905
+ * minimal.
14906
+ */
14907
+ var MotionTriggerStatusSchema = object({
13290
14908
  enabled: boolean(),
13291
14909
  /** Ms epoch of the last operator-driven change. */
13292
14910
  lastChangedAt: number()
13293
- }).extend({
14911
+ });
14912
+ /**
14913
+ * Persistent slice mirrored across restarts. The provider writes here
14914
+ * on every successful firmware fetch / setMotionTrigger push; the cap
14915
+ * router and admin-ui hero read straight from this snapshot via
14916
+ * `device.state.motionTrigger.value` instead of re-issuing a firmware
14917
+ * round-trip on every UI mount. `lastFetchedAt` lets the framework
14918
+ * helper (`createRuntimeStateBridge`) stale-check before deciding
14919
+ * whether to refresh from the camera.
14920
+ */
14921
+ var MotionTriggerRuntimeStateSchema = MotionTriggerStatusSchema.extend({
13294
14922
  /** Ms epoch of the last successful camera fetch (0 = never). */
13295
14923
  lastFetchedAt: number() });
13296
- DeviceType.Light, DeviceType.Siren, DeviceType.Switch, method(object({
13297
- deviceId: number().int().nonnegative(),
13298
- enabled: boolean()
13299
- }), _void(), {
13300
- kind: "mutation",
13301
- auth: "admin"
13302
- }), object({
13303
- deviceId: number(),
13304
- enabled: boolean(),
13305
- lastChangedAt: number()
13306
- });
14924
+ var motionTriggerCapability = {
14925
+ name: "motion-trigger",
14926
+ scope: "device",
14927
+ deviceNative: true,
14928
+ mode: "singleton",
14929
+ deviceTypes: [
14930
+ DeviceType.Light,
14931
+ DeviceType.Siren,
14932
+ DeviceType.Switch
14933
+ ],
14934
+ methods: { setMotionTrigger: method(object({
14935
+ deviceId: number().int().nonnegative(),
14936
+ enabled: boolean()
14937
+ }), _void(), {
14938
+ kind: "mutation",
14939
+ auth: "admin"
14940
+ }) },
14941
+ events: { onMotionTriggerChanged: { data: object({
14942
+ deviceId: number(),
14943
+ enabled: boolean(),
14944
+ lastChangedAt: number()
14945
+ }) } },
14946
+ status: {
14947
+ schema: MotionTriggerStatusSchema,
14948
+ kind: "command-driven"
14949
+ },
14950
+ runtimeState: MotionTriggerRuntimeStateSchema
14951
+ };
13307
14952
  /**
13308
14953
  * Motion-zones share the same MaskShape vocabulary as privacy-mask — the
13309
14954
  * on-camera motion-detection mask is a single `grid` region (a row-major
@@ -13316,7 +14961,9 @@ var MotionZoneRegionSchema = object({
13316
14961
  enabled: boolean(),
13317
14962
  shape: MaskGridShapeSchema
13318
14963
  });
13319
- object({
14964
+ /** Current on-camera motion-detection state — master enable + sensitivity +
14965
+ * the grid region(s). */
14966
+ var MotionZoneStatusSchema = object({
13320
14967
  enabled: boolean(),
13321
14968
  sensitivity: number(),
13322
14969
  /** Grid region(s). Today exactly one `grid` shape. */
@@ -13341,13 +14988,34 @@ var MotionZonePatchSchema = object({
13341
14988
  sensitivity: number().optional(),
13342
14989
  regions: array(MotionZoneRegionSchema).optional()
13343
14990
  });
13344
- DeviceType.Camera, method(object({ deviceId: number() }), MotionZoneOptionsSchema), method(object({
13345
- deviceId: number(),
13346
- patch: MotionZonePatchSchema
13347
- }), _void(), {
13348
- kind: "mutation",
13349
- auth: "admin"
13350
- });
14991
+ var motionZonesCapability = {
14992
+ name: "motion-zones",
14993
+ scope: "device",
14994
+ deviceNative: true,
14995
+ mode: "singleton",
14996
+ deviceTypes: [DeviceType.Camera],
14997
+ deviceConfig: { ui: {
14998
+ kind: "widget",
14999
+ widgetId: "host/motion-zones-grid",
15000
+ tab: "motion",
15001
+ label: "Motion Zones"
15002
+ } },
15003
+ methods: {
15004
+ getOptions: method(object({ deviceId: number() }), MotionZoneOptionsSchema),
15005
+ setZone: method(object({
15006
+ deviceId: number(),
15007
+ patch: MotionZonePatchSchema
15008
+ }), _void(), {
15009
+ kind: "mutation",
15010
+ auth: "admin"
15011
+ })
15012
+ },
15013
+ status: {
15014
+ schema: MotionZoneStatusSchema,
15015
+ kind: "poll"
15016
+ },
15017
+ runtimeState: MotionZoneStatusSchema
15018
+ };
13351
15019
  /**
13352
15020
  * On-camera AI object detection cap. Surfaces per-device the classes
13353
15021
  * the firmware can detect and the last-seen instance of each. The
@@ -13374,7 +15042,7 @@ var NativeDetectionSchema = object({
13374
15042
  /** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
13375
15043
  confidence: number().min(0).max(1).optional()
13376
15044
  });
13377
- object({
15045
+ var NativeObjectDetectionStatusSchema = object({
13378
15046
  /**
13379
15047
  * Last observed instance per class. Missing entries mean the class
13380
15048
  * is supported but nothing has been seen since the provider started.
@@ -13395,19 +15063,33 @@ object({
13395
15063
  * churn the tracker, so forwarding stays off until the operator enables it.
13396
15064
  */
13397
15065
  enabled: boolean()
13398
- }).extend({
15066
+ });
15067
+ var NativeObjectDetectionRuntimeStateSchema = NativeObjectDetectionStatusSchema.extend({
13399
15068
  /** Required by createRuntimeStateBridge — epoch ms of last refresh. */
13400
15069
  lastFetchedAt: number() });
13401
- DeviceType.Camera, method(object({
13402
- deviceId: number(),
13403
- enabled: boolean()
13404
- }), _void(), {
13405
- kind: "mutation",
13406
- auth: "admin"
13407
- }), object({
13408
- deviceId: number(),
13409
- detection: NativeDetectionSchema
13410
- });
15070
+ var nativeObjectDetectionCapability = {
15071
+ name: "native-object-detection",
15072
+ scope: "device",
15073
+ deviceNative: true,
15074
+ mode: "singleton",
15075
+ deviceTypes: [DeviceType.Camera],
15076
+ methods: { setEnabled: method(object({
15077
+ deviceId: number(),
15078
+ enabled: boolean()
15079
+ }), _void(), {
15080
+ kind: "mutation",
15081
+ auth: "admin"
15082
+ }) },
15083
+ events: { onDetected: { data: object({
15084
+ deviceId: number(),
15085
+ detection: NativeDetectionSchema
15086
+ }) } },
15087
+ status: {
15088
+ schema: NativeObjectDetectionStatusSchema,
15089
+ kind: "push"
15090
+ },
15091
+ runtimeState: NativeObjectDetectionRuntimeStateSchema
15092
+ };
13411
15093
  /**
13412
15094
  * Notification delivery cap. Models HA `notify.<service>` plus future
13413
15095
  * native targets (Telegram / Discord / ntfy / SMTP, …). Designed at
@@ -13459,7 +15141,7 @@ var NotifierSupportsSchema = object({
13459
15141
  * Pair with `DeviceFeature.NotifierRecipients`. */
13460
15142
  recipients: boolean()
13461
15143
  });
13462
- object({
15144
+ var NotifierStatusSchema = object({
13463
15145
  /** Ms epoch of the most recent successful send. 0 if none yet. */
13464
15146
  lastSentAt: number(),
13465
15147
  /** Failure description from the most recent send attempt. Null on
@@ -13500,23 +15182,67 @@ var NotifierSendResultSchema = object({
13500
15182
  /** Ms epoch when the notifier accepted the send (not when delivered). */
13501
15183
  acceptedAt: number()
13502
15184
  });
13503
- DeviceType.Notifier, method(NotifierSendInputSchema, NotifierSendResultSchema, {
13504
- kind: "mutation",
13505
- auth: "admin"
13506
- }), method(object({
13507
- deviceId: number().int().nonnegative(),
13508
- notificationId: string().min(1)
13509
- }), _void(), {
13510
- kind: "mutation",
13511
- auth: "admin"
13512
- }), object({
13513
- deviceId: number(),
13514
- notificationId: string(),
13515
- success: boolean(),
13516
- error: string().nullable(),
13517
- acceptedAt: number()
13518
- });
13519
- object({
15185
+ var notifierCapability = {
15186
+ name: "notifier",
15187
+ scope: "device",
15188
+ deviceNative: true,
15189
+ mode: "singleton",
15190
+ deviceTypes: [DeviceType.Notifier],
15191
+ methods: {
15192
+ send: method(NotifierSendInputSchema, NotifierSendResultSchema, {
15193
+ kind: "mutation",
15194
+ auth: "admin"
15195
+ }),
15196
+ cancel: method(object({
15197
+ deviceId: number().int().nonnegative(),
15198
+ notificationId: string().min(1)
15199
+ }), _void(), {
15200
+ kind: "mutation",
15201
+ auth: "admin"
15202
+ })
15203
+ },
15204
+ events: {
15205
+ /**
15206
+ * Emitted after every send attempt — success or failure. Subscribers
15207
+ * (admin UI history pane, automation engines, retry workers) react
15208
+ * without polling the provider's `lastSentAt`.
15209
+ */
15210
+ onSent: { data: object({
15211
+ deviceId: number(),
15212
+ notificationId: string(),
15213
+ success: boolean(),
15214
+ error: string().nullable(),
15215
+ acceptedAt: number()
15216
+ }) } },
15217
+ status: {
15218
+ schema: NotifierStatusSchema,
15219
+ kind: "command-driven"
15220
+ },
15221
+ /**
15222
+ * Runtime-state slice — diagnostics + supports matrix. UI compose
15223
+ * form reads `supports` to gate optional fields; history pane reads
15224
+ * `lastSentAt` / `lastError` / `queueDepth`.
15225
+ */
15226
+ runtimeState: NotifierStatusSchema
15227
+ };
15228
+ /**
15229
+ * Generic numeric sensor — last-resort fallback when no typed numeric
15230
+ * cap fits (HA `sensor` whose `device_class` we don't have a typed cap
15231
+ * for: water flow, distance, weight, frequency, signal strength, …).
15232
+ *
15233
+ * The `unit` and `precision` fields carry the live display metadata that
15234
+ * the upstream source provides with each state push (HA
15235
+ * `attributes.unit_of_measurement` / `attributes.suggested_display_precision`).
15236
+ * The UI reads them directly from the slice — they are the single source
15237
+ * of truth for generic numeric rendering and do NOT live on `sourceInfo`.
15238
+ *
15239
+ * Prefer the typed alternatives (`temperature-sensor`, `humidity-sensor`,
15240
+ * `ambient-light-sensor`, `pressure-sensor`, `power-meter`,
15241
+ * `air-quality-sensor`, `battery`) when the metric matches — export
15242
+ * adapters render those with the right service / category. Generic
15243
+ * numeric values land here.
15244
+ */
15245
+ var NumericSensorStatusSchema = object({
13520
15246
  value: number(),
13521
15247
  /** Display unit-of-measurement (e.g. 'dBm', 's', 'rpm', 'steps').
13522
15248
  * Populated live from the upstream source on each state push.
@@ -13529,7 +15255,19 @@ object({
13529
15255
  /** Ms epoch when the slice was last updated. */
13530
15256
  lastFetchedAt: number()
13531
15257
  });
13532
- DeviceType.Sensor;
15258
+ var numericSensorCapability = {
15259
+ name: "numeric-sensor",
15260
+ scope: "device",
15261
+ deviceNative: true,
15262
+ mode: "singleton",
15263
+ deviceTypes: [DeviceType.Sensor],
15264
+ methods: {},
15265
+ status: {
15266
+ schema: NumericSensorStatusSchema,
15267
+ kind: "push"
15268
+ },
15269
+ runtimeState: NumericSensorStatusSchema
15270
+ };
13533
15271
  /**
13534
15272
  * Feeder connectivity / power status — mirrors the HA petkit device-status
13535
15273
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
@@ -13541,7 +15279,7 @@ var PetFeederDeviceStatusSchema = _enum([
13541
15279
  "on_batteries"
13542
15280
  ]);
13543
15281
  var gramsPortion = number().int().min(4).max(200);
13544
- object({
15282
+ var PetFeederStatusSchema = object({
13545
15283
  /** Food currently in the bowl (grams). Null when the device has not
13546
15284
  * reported a reading yet. On dual-hopper models this is the combined
13547
15285
  * bowl reading; per-hopper levels live in `food1`/`food2`. */
@@ -13586,58 +15324,117 @@ object({
13586
15324
  /** Ms epoch when the slice was last refreshed from the cloud. */
13587
15325
  lastFetchedAt: number()
13588
15326
  });
13589
- DeviceType.PetFeeder, method(object({
13590
- deviceId: number().int().nonnegative(),
13591
- grams: gramsPortion.optional(),
13592
- hopper1: gramsPortion.optional(),
13593
- hopper2: gramsPortion.optional()
13594
- }), _void(), {
13595
- kind: "mutation",
13596
- auth: "admin"
13597
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13598
- kind: "mutation",
13599
- auth: "admin"
13600
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13601
- kind: "mutation",
13602
- auth: "admin"
13603
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13604
- kind: "mutation",
13605
- auth: "admin"
13606
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13607
- kind: "mutation",
13608
- auth: "admin"
13609
- }), method(object({
13610
- deviceId: number().int().nonnegative(),
13611
- soundId: number().int().nonnegative()
13612
- }), _void(), {
13613
- kind: "mutation",
13614
- auth: "admin"
13615
- }), method(object({
13616
- deviceId: number().int().nonnegative(),
13617
- on: boolean()
13618
- }), _void(), {
13619
- kind: "mutation",
13620
- auth: "admin"
13621
- }), method(object({
13622
- deviceId: number().int().nonnegative(),
13623
- on: boolean()
13624
- }), _void(), {
13625
- kind: "mutation",
13626
- auth: "admin"
13627
- }), method(object({
13628
- deviceId: number().int().nonnegative(),
13629
- on: boolean()
13630
- }), _void(), {
13631
- kind: "mutation",
13632
- auth: "admin"
13633
- }), method(object({
13634
- deviceId: number().int().nonnegative(),
13635
- level: number().int().nonnegative()
13636
- }), _void(), {
13637
- kind: "mutation",
13638
- auth: "admin"
13639
- });
13640
- object({
15327
+ var petFeederCapability = {
15328
+ name: "pet-feeder",
15329
+ scope: "device",
15330
+ deviceNative: true,
15331
+ mode: "singleton",
15332
+ deviceTypes: [DeviceType.PetFeeder],
15333
+ methods: {
15334
+ /**
15335
+ * Dispense food now. Single-hopper feeders take `grams`; dual-hopper
15336
+ * feeders (D4S/D4SH) accept `hopper1`/`hopper2` to target one or both
15337
+ * hoppers. All portions honour the 4–200 g hardware range. At least
15338
+ * one of the three must be present — the provider rejects an empty
15339
+ * request.
15340
+ */
15341
+ feed: method(object({
15342
+ deviceId: number().int().nonnegative(),
15343
+ grams: gramsPortion.optional(),
15344
+ hopper1: gramsPortion.optional(),
15345
+ hopper2: gramsPortion.optional()
15346
+ }), _void(), {
15347
+ kind: "mutation",
15348
+ auth: "admin"
15349
+ }),
15350
+ /** Cancel an in-progress manual feed. */
15351
+ cancelFeed: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15352
+ kind: "mutation",
15353
+ auth: "admin"
15354
+ }),
15355
+ /** Reset the desiccant "days remaining" counter after replacing it. */
15356
+ resetDesiccant: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15357
+ kind: "mutation",
15358
+ auth: "admin"
15359
+ }),
15360
+ /** Mark a hopper as refilled (D4H/D4S/D4SH). */
15361
+ markFoodReplenished: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15362
+ kind: "mutation",
15363
+ auth: "admin"
15364
+ }),
15365
+ /** Call the pet with the recorded prompt (D3). */
15366
+ callPet: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15367
+ kind: "mutation",
15368
+ auth: "admin"
15369
+ }),
15370
+ /** Play a stored sound by id (D3 / D4H / D4SH). */
15371
+ playSound: method(object({
15372
+ deviceId: number().int().nonnegative(),
15373
+ soundId: number().int().nonnegative()
15374
+ }), _void(), {
15375
+ kind: "mutation",
15376
+ auth: "admin"
15377
+ }),
15378
+ /** Toggle the child-lock (manual-lock) setting. */
15379
+ setChildLock: method(object({
15380
+ deviceId: number().int().nonnegative(),
15381
+ on: boolean()
15382
+ }), _void(), {
15383
+ kind: "mutation",
15384
+ auth: "admin"
15385
+ }),
15386
+ /** Toggle the front indicator light. */
15387
+ setIndicatorLight: method(object({
15388
+ deviceId: number().int().nonnegative(),
15389
+ on: boolean()
15390
+ }), _void(), {
15391
+ kind: "mutation",
15392
+ auth: "admin"
15393
+ }),
15394
+ /** Toggle the dispense chime. */
15395
+ setFeedSound: method(object({
15396
+ deviceId: number().int().nonnegative(),
15397
+ on: boolean()
15398
+ }), _void(), {
15399
+ kind: "mutation",
15400
+ auth: "admin"
15401
+ }),
15402
+ /** Set the speaker / prompt volume level. */
15403
+ setVolume: method(object({
15404
+ deviceId: number().int().nonnegative(),
15405
+ level: number().int().nonnegative()
15406
+ }), _void(), {
15407
+ kind: "mutation",
15408
+ auth: "admin"
15409
+ })
15410
+ },
15411
+ status: {
15412
+ schema: PetFeederStatusSchema,
15413
+ kind: "poll"
15414
+ },
15415
+ /**
15416
+ * Runtime-state slice — mirrored by the kernel. UI feeder cards read
15417
+ * the full slice via `device.state.petFeeder.value` and refresh on
15418
+ * every poll without re-querying the provider.
15419
+ */
15420
+ runtimeState: PetFeederStatusSchema
15421
+ };
15422
+ /**
15423
+ * Multi-metric electrical meter. One slice can carry any combination
15424
+ * of instantaneous power (W), cumulative energy (kWh), voltage (V),
15425
+ * and current (A) — all fields optional so a single-metric source
15426
+ * (HA `sensor` with `device_class: power`) populates only what it
15427
+ * has and aggregators (energy dashboards, billing exports) compose
15428
+ * across providers without needing one cap per metric.
15429
+ *
15430
+ * Trade-off acknowledged: a HomeKit export that wants to surface
15431
+ * "power" and "energy" as separate services has to crack open the
15432
+ * slice and emit two characteristics from one cap. Worth it to avoid
15433
+ * exploding the cap catalog with `power-w` / `energy-kwh` /
15434
+ * `voltage-v` / `current-a` quartets that always travel together
15435
+ * in real-world devices.
15436
+ */
15437
+ var PowerMeterStatusSchema = object({
13641
15438
  /** Instantaneous power draw in watts. */
13642
15439
  watts: number().optional(),
13643
15440
  /** Cumulative energy in kilowatt-hours since the meter was reset. */
@@ -13664,7 +15461,19 @@ object({
13664
15461
  * auto-formatting when absent. */
13665
15462
  precision: number().int().min(0).max(10).optional()
13666
15463
  });
13667
- DeviceType.Sensor;
15464
+ var powerMeterCapability = {
15465
+ name: "power-meter",
15466
+ scope: "device",
15467
+ deviceNative: true,
15468
+ mode: "singleton",
15469
+ deviceTypes: [DeviceType.Sensor],
15470
+ methods: {},
15471
+ status: {
15472
+ schema: PowerMeterStatusSchema,
15473
+ kind: "push"
15474
+ },
15475
+ runtimeState: PowerMeterStatusSchema
15476
+ };
13668
15477
  /**
13669
15478
  * Presence cap. Models HA `person.*` and `device_tracker.*` entities
13670
15479
  * on `DeviceType.Presence`. Read-only — no setters: presence is
@@ -13689,7 +15498,7 @@ var GpsLocationSchema = object({
13689
15498
  /** Reported accuracy in meters (lower = better). */
13690
15499
  accuracyMeters: number().nonnegative()
13691
15500
  });
13692
- object({
15501
+ var PresenceStatusSchema = object({
13693
15502
  /** `home` / `not_home` / any user-defined zone name. */
13694
15503
  state: string(),
13695
15504
  /** Optional textual location label (zone name, city, address). Null
@@ -13703,8 +15512,34 @@ object({
13703
15512
  /** Ms epoch when the slice was last updated. */
13704
15513
  lastChangedAt: number()
13705
15514
  });
13706
- DeviceType.Presence;
13707
- object({
15515
+ var presenceCapability = {
15516
+ name: "presence",
15517
+ scope: "device",
15518
+ deviceNative: true,
15519
+ mode: "singleton",
15520
+ deviceTypes: [DeviceType.Presence],
15521
+ methods: {},
15522
+ status: {
15523
+ schema: PresenceStatusSchema,
15524
+ kind: "push"
15525
+ },
15526
+ /**
15527
+ * Runtime-state slice — mirrored by the kernel. UI presence card
15528
+ * reads `state` + `location` for the text label; iff `gps !== null`
15529
+ * the map pin is rendered (use `DeviceFeature.PresenceGps` for the
15530
+ * pre-fetch fast-path check).
15531
+ */
15532
+ runtimeState: PresenceStatusSchema
15533
+ };
15534
+ /**
15535
+ * Atmospheric pressure reading in hectopascals. Drives Home Assistant
15536
+ * `sensor` entries with `device_class: pressure`.
15537
+ *
15538
+ * Unit normalisation: hPa. The canonical display unit (`hPa`) is a
15539
+ * descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
15540
+ * `sourceInfo`.
15541
+ */
15542
+ var PressureSensorStatusSchema = object({
13708
15543
  /** Current pressure in hPa. */
13709
15544
  hpa: number(),
13710
15545
  /** Ms epoch when the slice was last updated. */
@@ -13719,7 +15554,19 @@ object({
13719
15554
  * auto-formatting when absent. */
13720
15555
  precision: number().int().min(0).max(10).optional()
13721
15556
  });
13722
- DeviceType.Sensor;
15557
+ var pressureSensorCapability = {
15558
+ name: "pressure-sensor",
15559
+ scope: "device",
15560
+ deviceNative: true,
15561
+ mode: "singleton",
15562
+ deviceTypes: [DeviceType.Sensor],
15563
+ methods: {},
15564
+ status: {
15565
+ schema: PressureSensorStatusSchema,
15566
+ kind: "push"
15567
+ },
15568
+ runtimeState: PressureSensorStatusSchema
15569
+ };
13723
15570
  /**
13724
15571
  * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
13725
15572
  * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
@@ -13738,7 +15585,8 @@ var PrivacyMaskRegionSchema = object({
13738
15585
  enabled: boolean(),
13739
15586
  shape: PrivacyMaskShapeSchema
13740
15587
  });
13741
- object({
15588
+ /** Current on-camera privacy-mask state — master enable + zones. */
15589
+ var PrivacyMaskStatusSchema = object({
13742
15590
  enabled: boolean(),
13743
15591
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
13744
15592
  regions: array(PrivacyMaskRegionSchema),
@@ -13758,13 +15606,34 @@ var PrivacyMaskPatchSchema = object({
13758
15606
  enabled: boolean().optional(),
13759
15607
  regions: array(PrivacyMaskRegionSchema).optional()
13760
15608
  });
13761
- DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSchema), method(object({
13762
- deviceId: number(),
13763
- patch: PrivacyMaskPatchSchema
13764
- }), _void(), {
13765
- kind: "mutation",
13766
- auth: "admin"
13767
- });
15609
+ var privacyMaskCapability = {
15610
+ name: "privacy-mask",
15611
+ scope: "device",
15612
+ deviceNative: true,
15613
+ mode: "singleton",
15614
+ deviceTypes: [DeviceType.Camera],
15615
+ deviceConfig: { ui: {
15616
+ kind: "widget",
15617
+ widgetId: "host/privacy-mask-grid",
15618
+ tab: "image",
15619
+ label: "Privacy Mask"
15620
+ } },
15621
+ methods: {
15622
+ getOptions: method(object({ deviceId: number() }), PrivacyMaskOptionsSchema),
15623
+ setMask: method(object({
15624
+ deviceId: number(),
15625
+ patch: PrivacyMaskPatchSchema
15626
+ }), _void(), {
15627
+ kind: "mutation",
15628
+ auth: "admin"
15629
+ })
15630
+ },
15631
+ status: {
15632
+ schema: PrivacyMaskStatusSchema,
15633
+ kind: "poll"
15634
+ },
15635
+ runtimeState: PrivacyMaskStatusSchema
15636
+ };
13768
15637
  var PtzAutotrackSettingsSchema = object({
13769
15638
  targetType: string().describe("Vendor target string (people/vehicle/pet); empty = camera default"),
13770
15639
  stopDelaySeconds: number().int().min(0).max(300),
@@ -13804,19 +15673,85 @@ var PtzAutotrackStatusSchema = object({
13804
15673
  */
13805
15674
  supportedTargetTypes: array(PtzAutotrackTargetOptionSchema)
13806
15675
  });
13807
- PtzAutotrackStatusSchema.extend({
15676
+ /**
15677
+ * Runtime-state slice owned by this cap. Persists the last-known
15678
+ * camera-derived snapshot across restarts, so cold-start callers
15679
+ * have a meaningful baseline even before the first refresh round-trip.
15680
+ *
15681
+ * Adds `lastFetchedAt` on top of the status shape so the framework
15682
+ * (or the provider) can stale-check before deciding whether to
15683
+ * round-trip the camera again.
15684
+ */
15685
+ var PtzAutotrackRuntimeStateSchema = PtzAutotrackStatusSchema.extend({
13808
15686
  /** Ms epoch of the last successful camera fetch (0 = never). */
13809
15687
  lastFetchedAt: number() });
13810
- DeviceType.Camera, method(object({ deviceId: number() }), PtzAutotrackStatusSchema), method(object({
13811
- deviceId: number(),
13812
- enabled: boolean()
13813
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzAutotrackSettingsSchema.nullable()), method(object({
13814
- deviceId: number(),
13815
- settings: PtzAutotrackSettingsSchema.partial()
13816
- }), _void(), { kind: "mutation" }), object({
13817
- deviceId: number(),
13818
- status: PtzAutotrackStatusSchema
13819
- });
15688
+ var ptzAutotrackCapability = {
15689
+ name: "ptz-autotrack",
15690
+ scope: "device",
15691
+ deviceNative: true,
15692
+ mode: "singleton",
15693
+ deviceTypes: [DeviceType.Camera],
15694
+ deviceConfig: { ui: {
15695
+ kind: "widget",
15696
+ widgetId: "host/ptz-autotrack",
15697
+ tab: "ptz",
15698
+ topTab: true,
15699
+ label: "Auto-Tracking",
15700
+ order: 5
15701
+ } },
15702
+ methods: {
15703
+ /**
15704
+ * Read the current on/off state + last-applied settings.
15705
+ * Drivers may serve from a cache + refresh on a schedule —
15706
+ * callers should NOT poll faster than ~5s.
15707
+ */
15708
+ getStatus: method(object({ deviceId: number() }), PtzAutotrackStatusSchema),
15709
+ /**
15710
+ * Arm or disarm autotrack. Resolves once the camera has
15711
+ * acknowledged the transition; the `onStatusChanged` event
15712
+ * fires shortly after with the live state.
15713
+ */
15714
+ setEnabled: method(object({
15715
+ deviceId: number(),
15716
+ enabled: boolean()
15717
+ }), _void(), { kind: "mutation" }),
15718
+ /**
15719
+ * Read settings (target type + delays). Mirrors the
15720
+ * `getStatus().currentSettings` payload but exposes it as a
15721
+ * standalone read for callers that want settings without the
15722
+ * status surface around them. `null` until the driver has
15723
+ * harvested the firmware state at least once.
15724
+ */
15725
+ getSettings: method(object({ deviceId: number() }), PtzAutotrackSettingsSchema.nullable()),
15726
+ /**
15727
+ * Update one or more settings. Partial — keys that aren't
15728
+ * supplied keep their last persisted value. The driver is
15729
+ * responsible for clamping vendor-specific ranges if narrower
15730
+ * than the cross-vendor max declared on the schema.
15731
+ */
15732
+ setSettings: method(object({
15733
+ deviceId: number(),
15734
+ settings: PtzAutotrackSettingsSchema.partial()
15735
+ }), _void(), { kind: "mutation" })
15736
+ },
15737
+ events: { onStatusChanged: { data: object({
15738
+ deviceId: number(),
15739
+ status: PtzAutotrackStatusSchema
15740
+ }) } },
15741
+ status: {
15742
+ schema: PtzAutotrackStatusSchema,
15743
+ kind: "command-driven"
15744
+ },
15745
+ /**
15746
+ * Persistent slice mirrored across restarts. The provider writes
15747
+ * here on every successful firmware fetch / settings push;
15748
+ * `getStatus` and `getSettings` then read this snapshot synchronously
15749
+ * with a stale-driven async refresh in the background. Keeps the
15750
+ * fetch / cache / fallback logic out of the four cap methods —
15751
+ * they become trampolines over `runtimeState`.
15752
+ */
15753
+ runtimeState: PtzAutotrackRuntimeStateSchema
15754
+ };
13820
15755
  /**
13821
15756
  * scene-monitor — device-scoped reference-region state cap. An operator marks
13822
15757
  * a rect ROI on a camera frame and names one or more states; the engine
@@ -13886,72 +15821,108 @@ var SceneMonitorStatusSchema = object({
13886
15821
  monitors: array(SceneMonitorSchema),
13887
15822
  lastFetchedAt: number()
13888
15823
  });
13889
- DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSchema), method(object({
13890
- deviceId: number(),
13891
- label: string(),
13892
- roi: MaskRectShapeSchema,
13893
- check: SceneCheckSchema,
13894
- triggerMode: _enum([
13895
- "periodic",
13896
- "on-motion",
13897
- "both"
13898
- ]).optional(),
13899
- checkIntervalSec: number().optional()
13900
- }), SceneMonitorSchema, {
13901
- kind: "mutation",
13902
- auth: "admin"
13903
- }), method(object({
13904
- deviceId: number(),
13905
- monitorId: string(),
13906
- patch: object({
13907
- label: string().optional(),
13908
- roi: MaskRectShapeSchema.optional(),
13909
- enabled: boolean().optional(),
13910
- triggerMode: _enum([
13911
- "periodic",
13912
- "on-motion",
13913
- "both"
13914
- ]).optional(),
13915
- checkIntervalSec: number().optional(),
13916
- check: SceneCheckSchema.optional()
13917
- })
13918
- }), _void(), {
13919
- kind: "mutation",
13920
- auth: "admin"
13921
- }), method(object({
13922
- deviceId: number(),
13923
- monitorId: string()
13924
- }), _void(), {
13925
- kind: "mutation",
13926
- auth: "admin"
13927
- }), method(object({
13928
- deviceId: number(),
13929
- monitorId: string(),
13930
- stateId: string().optional(),
13931
- label: string().optional(),
13932
- condition: SceneConditionSchema.optional()
13933
- }), object({
13934
- stateId: string(),
13935
- referenceCount: number()
13936
- }), {
13937
- kind: "mutation",
13938
- auth: "admin"
13939
- }), method(object({
13940
- deviceId: number(),
13941
- monitorId: string(),
13942
- stateId: string(),
13943
- index: number()
13944
- }), _void(), {
13945
- kind: "mutation",
13946
- auth: "admin"
13947
- }), method(object({
13948
- deviceId: number(),
13949
- monitorId: string()
13950
- }), _void(), {
13951
- kind: "mutation",
13952
- auth: "admin"
13953
- });
13954
- object({
15824
+ var sceneMonitorCapability = {
15825
+ name: "scene-monitor",
15826
+ scope: "device",
15827
+ mode: "singleton",
15828
+ kind: "wrapper",
15829
+ defaultActive: true,
15830
+ deviceTypes: [DeviceType.Camera],
15831
+ deviceConfig: { ui: {
15832
+ kind: "widget",
15833
+ widgetId: "host/scene-monitor-editor",
15834
+ tab: "scenes",
15835
+ label: "Scenes"
15836
+ } },
15837
+ methods: {
15838
+ listScenes: method(object({ deviceId: number() }), SceneMonitorStatusSchema),
15839
+ createScene: method(object({
15840
+ deviceId: number(),
15841
+ label: string(),
15842
+ roi: MaskRectShapeSchema,
15843
+ check: SceneCheckSchema,
15844
+ triggerMode: _enum([
15845
+ "periodic",
15846
+ "on-motion",
15847
+ "both"
15848
+ ]).optional(),
15849
+ checkIntervalSec: number().optional()
15850
+ }), SceneMonitorSchema, {
15851
+ kind: "mutation",
15852
+ auth: "admin"
15853
+ }),
15854
+ updateScene: method(object({
15855
+ deviceId: number(),
15856
+ monitorId: string(),
15857
+ patch: object({
15858
+ label: string().optional(),
15859
+ roi: MaskRectShapeSchema.optional(),
15860
+ enabled: boolean().optional(),
15861
+ triggerMode: _enum([
15862
+ "periodic",
15863
+ "on-motion",
15864
+ "both"
15865
+ ]).optional(),
15866
+ checkIntervalSec: number().optional(),
15867
+ check: SceneCheckSchema.optional()
15868
+ })
15869
+ }), _void(), {
15870
+ kind: "mutation",
15871
+ auth: "admin"
15872
+ }),
15873
+ deleteScene: method(object({
15874
+ deviceId: number(),
15875
+ monitorId: string()
15876
+ }), _void(), {
15877
+ kind: "mutation",
15878
+ auth: "admin"
15879
+ }),
15880
+ captureReference: method(object({
15881
+ deviceId: number(),
15882
+ monitorId: string(),
15883
+ stateId: string().optional(),
15884
+ label: string().optional(),
15885
+ condition: SceneConditionSchema.optional()
15886
+ }), object({
15887
+ stateId: string(),
15888
+ referenceCount: number()
15889
+ }), {
15890
+ kind: "mutation",
15891
+ auth: "admin"
15892
+ }),
15893
+ deleteReference: method(object({
15894
+ deviceId: number(),
15895
+ monitorId: string(),
15896
+ stateId: string(),
15897
+ index: number()
15898
+ }), _void(), {
15899
+ kind: "mutation",
15900
+ auth: "admin"
15901
+ }),
15902
+ recheckNow: method(object({
15903
+ deviceId: number(),
15904
+ monitorId: string()
15905
+ }), _void(), {
15906
+ kind: "mutation",
15907
+ auth: "admin"
15908
+ })
15909
+ },
15910
+ status: {
15911
+ schema: SceneMonitorStatusSchema,
15912
+ kind: "push"
15913
+ },
15914
+ runtimeState: SceneMonitorStatusSchema
15915
+ };
15916
+ /**
15917
+ * Script-runner cap. Models HA `script.*` entities on
15918
+ * `DeviceType.Script`. A Script is a pre-recorded action sequence
15919
+ * that can be invoked imperatively — optionally with a variables
15920
+ * map when the script declares input fields.
15921
+ *
15922
+ * Variable-support is signalled by `DeviceFeature.ScriptVariables`
15923
+ * so the UI gates a "with parameters" form without a slice fetch.
15924
+ */
15925
+ var ScriptRunnerStatusSchema = object({
13955
15926
  /** Whether the script is currently executing. */
13956
15927
  isRunning: boolean(),
13957
15928
  /** Ms epoch of the last invocation start. 0 when never run. */
@@ -13965,25 +15936,68 @@ object({
13965
15936
  /** Ms epoch when the slice was last updated. */
13966
15937
  lastChangedAt: number()
13967
15938
  });
13968
- DeviceType.Script, method(object({
13969
- deviceId: number().int().nonnegative(),
13970
- /** Optional variables map — passed through to the upstream
13971
- * script. Provider rejects when the script doesn't declare
13972
- * input fields (gated by `DeviceFeature.ScriptVariables`). */
13973
- variables: record(string(), unknown()).optional()
13974
- }), _void(), {
13975
- kind: "mutation",
13976
- auth: "admin"
13977
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
13978
- kind: "mutation",
13979
- auth: "admin"
13980
- });
13981
- object({
15939
+ var scriptRunnerCapability = {
15940
+ name: "script-runner",
15941
+ scope: "device",
15942
+ deviceNative: true,
15943
+ mode: "singleton",
15944
+ deviceTypes: [DeviceType.Script],
15945
+ methods: {
15946
+ run: method(object({
15947
+ deviceId: number().int().nonnegative(),
15948
+ /** Optional variables map — passed through to the upstream
15949
+ * script. Provider rejects when the script doesn't declare
15950
+ * input fields (gated by `DeviceFeature.ScriptVariables`). */
15951
+ variables: record(string(), unknown()).optional()
15952
+ }), _void(), {
15953
+ kind: "mutation",
15954
+ auth: "admin"
15955
+ }),
15956
+ /** Cancel a running script. Provider rejects when the script
15957
+ * isn't currently running. */
15958
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
15959
+ kind: "mutation",
15960
+ auth: "admin"
15961
+ })
15962
+ },
15963
+ status: {
15964
+ schema: ScriptRunnerStatusSchema,
15965
+ kind: "push"
15966
+ },
15967
+ /**
15968
+ * Runtime-state slice — mirrored by the kernel. UI script tile reads
15969
+ * `isRunning` to render a spinner during execution and surfaces
15970
+ * `lastError` / `lastRunSuccess` in the recent-runs panel.
15971
+ */
15972
+ runtimeState: ScriptRunnerStatusSchema
15973
+ };
15974
+ /**
15975
+ * Smoke alarm sensor — boolean "is smoke currently detected" with
15976
+ * timestamp of the last transition. Drives Home Assistant
15977
+ * `binary_sensor` entries with `device_class: smoke`.
15978
+ *
15979
+ * Push-driven: a smoke event is critical, so the slice updates
15980
+ * immediately on the upstream signal. Auto-clearing back to false is
15981
+ * provider-controlled (some alarms latch until manually reset).
15982
+ */
15983
+ var SmokeStatusSchema = object({
13982
15984
  detected: boolean(),
13983
15985
  /** Ms epoch of the last transition. 0 if never observed. */
13984
15986
  lastChangedAt: number()
13985
15987
  });
13986
- DeviceType.Sensor;
15988
+ var smokeCapability = {
15989
+ name: "smoke",
15990
+ scope: "device",
15991
+ deviceNative: true,
15992
+ mode: "singleton",
15993
+ deviceTypes: [DeviceType.Sensor],
15994
+ methods: {},
15995
+ status: {
15996
+ schema: SmokeStatusSchema,
15997
+ kind: "push"
15998
+ },
15999
+ runtimeState: SmokeStatusSchema
16000
+ };
13987
16001
  /** One of the camera's stream profiles. */
13988
16002
  var StreamProfileSchema = _enum([
13989
16003
  "main",
@@ -14006,7 +16020,7 @@ var StreamProfileConfigSchema = object({
14006
16020
  gop: number().optional(),
14007
16021
  audio: boolean().optional()
14008
16022
  });
14009
- object({
16023
+ var StreamParamsStatusSchema = object({
14010
16024
  /** Per-profile current config. A profile absent = the camera doesn't have it. */
14011
16025
  main: StreamProfileConfigSchema.optional(),
14012
16026
  sub: StreamProfileConfigSchema.optional(),
@@ -14060,39 +16074,150 @@ var StreamProfilePatchSchema = object({
14060
16074
  gop: number().optional(),
14061
16075
  audio: boolean().optional()
14062
16076
  });
14063
- DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
14064
- deviceId: number(),
14065
- profile: StreamProfileSchema,
14066
- patch: StreamProfilePatchSchema
14067
- }), _void(), {
14068
- kind: "mutation",
14069
- auth: "admin"
14070
- }), method(object({ deviceId: number() }), unknown().nullable());
14071
- object({
16077
+ var streamParamsCapability = {
16078
+ name: "stream-params",
16079
+ scope: "device",
16080
+ deviceNative: true,
16081
+ mode: "singleton",
16082
+ deviceTypes: [DeviceType.Camera],
16083
+ deviceConfig: { ui: {
16084
+ kind: "derived-form",
16085
+ builderId: "stream-params",
16086
+ tab: "streaming"
16087
+ } },
16088
+ methods: {
16089
+ getOptions: method(object({ deviceId: number() }), StreamParamsOptionsSchema),
16090
+ setProfile: method(object({
16091
+ deviceId: number(),
16092
+ profile: StreamProfileSchema,
16093
+ patch: StreamProfilePatchSchema
16094
+ }), _void(), {
16095
+ kind: "mutation",
16096
+ auth: "admin"
16097
+ }),
16098
+ /**
16099
+ * Build the `ConfigUISchema` (admin-ui `ConfigFormBuilder` input
16100
+ * shape) for this camera's stream-encoder settings — one section per
16101
+ * profile (main / sub / ext) with the resolution / codec / framerate
16102
+ * / bitrate / bitrate-mode / encoder-profile / GOP controls the
16103
+ * firmware actually exposes.
16104
+ *
16105
+ * Driven by `getOptions` (camera-probed availability) + `getStatus`
16106
+ * (current per-profile config); each field's `default` is seeded
16107
+ * from the live config so the form renders the camera state in one
16108
+ * pass. Returns `null` when the camera exposes no configurable
16109
+ * stream property — the renderer then shows the unsupported message.
16110
+ *
16111
+ * Output is `z.unknown().nullable()` — the same convention every
16112
+ * other `ConfigUISchema`-returning cap method uses (`device-ops`,
16113
+ * `device-manager`); `ConfigUISchema` is a TS-only type with no
16114
+ * companion Zod schema, and a concrete object would collapse
16115
+ * unrelated AppRouter branches to `unknown` during codegen.
16116
+ */
16117
+ getConfigSchema: method(object({ deviceId: number() }), unknown().nullable())
16118
+ },
16119
+ status: {
16120
+ schema: StreamParamsStatusSchema,
16121
+ kind: "poll"
16122
+ },
16123
+ runtimeState: StreamParamsStatusSchema
16124
+ };
16125
+ /**
16126
+ * Generic on/off switch cap for accessory children (siren, floodlight,
16127
+ * spotlight, PIR toggle, chime silencer, autotrack enable, …). The cap
16128
+ * owns ONLY the boolean state — driver-specific settings (brightness,
16129
+ * sensitivity, schedule, duration) live on the device's own
16130
+ * `getSettingsUISchema()` surface.
16131
+ */
16132
+ var SwitchStatusSchema = object({
14072
16133
  on: boolean(),
14073
16134
  /** Ms epoch of the last state change. Useful for UI "X minutes ago". */
14074
16135
  lastChangedAt: number()
14075
16136
  });
14076
- DeviceType.Switch, DeviceType.Siren, DeviceType.Light, method(object({
14077
- deviceId: number(),
14078
- on: boolean()
14079
- }), _void(), {
14080
- kind: "mutation",
14081
- auth: "admin"
14082
- }), object({
14083
- deviceId: number(),
14084
- on: boolean(),
14085
- lastChangedAt: number()
14086
- });
14087
- object({
16137
+ var switchCapability = {
16138
+ name: "switch",
16139
+ scope: "device",
16140
+ deviceNative: true,
16141
+ mode: "singleton",
16142
+ deviceTypes: [
16143
+ DeviceType.Switch,
16144
+ DeviceType.Siren,
16145
+ DeviceType.Light
16146
+ ],
16147
+ methods: { setState: method(object({
16148
+ deviceId: number(),
16149
+ on: boolean()
16150
+ }), _void(), {
16151
+ kind: "mutation",
16152
+ auth: "admin"
16153
+ }) },
16154
+ events: { onStateChanged: { data: object({
16155
+ deviceId: number(),
16156
+ on: boolean(),
16157
+ lastChangedAt: number()
16158
+ }) } },
16159
+ status: {
16160
+ schema: SwitchStatusSchema,
16161
+ kind: "command-driven"
16162
+ },
16163
+ /**
16164
+ * Runtime-state slice — the last applied on/off state, mirrored by
16165
+ * the kernel. Read via `device.state.switch.value`; UI toggles do
16166
+ * not need to re-query the provider after a setState mutation.
16167
+ */
16168
+ runtimeState: SwitchStatusSchema,
16169
+ settings: { bindings: [{
16170
+ kind: "scalar",
16171
+ statusPath: "on",
16172
+ method: "setState",
16173
+ valueArg: "on",
16174
+ field: {
16175
+ label: "On",
16176
+ kind: "boolean"
16177
+ }
16178
+ }] }
16179
+ };
16180
+ /**
16181
+ * Tamper / case-open detection sensor. Drives Home Assistant
16182
+ * `binary_sensor` entries with `device_class: tamper`. Push-driven.
16183
+ */
16184
+ var TamperStatusSchema = object({
14088
16185
  /** True when the device's tamper switch / case-open contact is
14089
16186
  * currently triggered. */
14090
16187
  tampered: boolean(),
14091
16188
  /** Ms epoch of the last transition. 0 if never observed. */
14092
16189
  lastChangedAt: number()
14093
16190
  });
14094
- DeviceType.Sensor;
14095
- object({
16191
+ var tamperCapability = {
16192
+ name: "tamper",
16193
+ scope: "device",
16194
+ deviceNative: true,
16195
+ mode: "singleton",
16196
+ deviceTypes: [DeviceType.Sensor],
16197
+ methods: {},
16198
+ status: {
16199
+ schema: TamperStatusSchema,
16200
+ kind: "push"
16201
+ },
16202
+ runtimeState: TamperStatusSchema
16203
+ };
16204
+ /**
16205
+ * Single-metric temperature reading. Drives Home Assistant `sensor`
16206
+ * entries with `device_class: temperature` and any future native
16207
+ * thermometer.
16208
+ *
16209
+ * Unit normalisation: providers convert to Celsius before storing.
16210
+ * The slice value is always Celsius so cross-cap aggregators
16211
+ * (climate-control's `currentTemp`, energy analytics) can compose
16212
+ * without per-source unit fixups. The canonical display unit (`°C`) is
16213
+ * a descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
16214
+ * `sourceInfo`.
16215
+ *
16216
+ * Status `lastFetchedAt` lets staleness-aware consumers detect a
16217
+ * frozen feed (provider hung) distinct from a "temperature hasn't
16218
+ * changed" steady state.
16219
+ */
16220
+ var TemperatureSensorStatusSchema = object({
14096
16221
  /** Current temperature in Celsius. */
14097
16222
  celsius: number(),
14098
16223
  /** Ms epoch when the slice was last updated (push or poll). */
@@ -14108,8 +16233,29 @@ object({
14108
16233
  * auto-formatting when absent. */
14109
16234
  precision: number().int().min(0).max(10).optional()
14110
16235
  });
14111
- DeviceType.Sensor, DeviceType.Thermostat;
14112
- object({
16236
+ var temperatureSensorCapability = {
16237
+ name: "temperature-sensor",
16238
+ scope: "device",
16239
+ deviceNative: true,
16240
+ mode: "singleton",
16241
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat],
16242
+ methods: {},
16243
+ status: {
16244
+ schema: TemperatureSensorStatusSchema,
16245
+ kind: "push"
16246
+ },
16247
+ runtimeState: TemperatureSensorStatusSchema
16248
+ };
16249
+ /**
16250
+ * Firmware/software update entity. Installed on a `DeviceType.Update` device.
16251
+ * Surfaces current vs available version, whether an update is available, the
16252
+ * update state, and an install action. Read-mostly and POLL-driven: backends
16253
+ * such as the Homematic CCU do not broadcast firmware-descriptor changes, so
16254
+ * the client re-queries `getStatus` (which reads the live descriptor) rather
16255
+ * than relying on push delivery. `installUpdate` triggers the install
16256
+ * (best-effort — providers throw if the backend rejects it).
16257
+ */
16258
+ var UpdateStatusSchema = object({
14113
16259
  currentVersion: string().nullable(),
14114
16260
  availableVersion: string().nullable(),
14115
16261
  /**
@@ -14122,10 +16268,22 @@ object({
14122
16268
  state: string().nullable(),
14123
16269
  inProgress: boolean()
14124
16270
  });
14125
- DeviceType.Update, method(_void(), _void(), {
14126
- kind: "mutation",
14127
- auth: "admin"
14128
- });
16271
+ var updateCapability = {
16272
+ name: "update",
16273
+ scope: "device",
16274
+ deviceNative: true,
16275
+ mode: "singleton",
16276
+ deviceTypes: [DeviceType.Update],
16277
+ methods: { installUpdate: method(_void(), _void(), {
16278
+ kind: "mutation",
16279
+ auth: "admin"
16280
+ }) },
16281
+ status: {
16282
+ schema: UpdateStatusSchema,
16283
+ kind: "poll"
16284
+ },
16285
+ runtimeState: UpdateStatusSchema
16286
+ };
14129
16287
  /**
14130
16288
  * Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
14131
16289
  * cleaning lifecycle plus a return-to-base / locate surface and an
@@ -14177,7 +16335,7 @@ var TankStatusSchema = object({
14177
16335
  "full"
14178
16336
  ]).nullable()
14179
16337
  });
14180
- object({
16338
+ var VacuumControlStatusSchema = object({
14181
16339
  /** Lifecycle state of the vacuum. */
14182
16340
  state: VacuumStateSchema,
14183
16341
  /** 0..100 battery percentage. Null when the device has no battery
@@ -14189,45 +16347,68 @@ object({
14189
16347
  /** Speed tokens the hardware accepts — drives the UI selector. */
14190
16348
  availableFanSpeeds: array(string()),
14191
16349
  /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
14192
- cleanWater: TankStatusSchema.nullable(),
14193
- /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
14194
- dirtyWater: TankStatusSchema.nullable(),
14195
- /** Detergent tank. Null when the hardware has no detergent tank. */
14196
- detergent: TankStatusSchema.nullable(),
14197
- /** Dust bin. Null when the hardware has no dust bin. */
14198
- dustBin: TankStatusSchema.nullable(),
14199
- /** 0..100 cleaning-completion percentage of the current task, or null. */
14200
- progressPercent: number().min(0).max(100).nullable(),
14201
- /** Current error code (0 / null = no error). */
14202
- errorCode: number().nullable(),
14203
- /** Human label for {@link errorCode}, or null when none / undecodable. */
14204
- errorLabel: string().nullable(),
14205
- /** Ms epoch when the slice was last updated. */
14206
- lastChangedAt: number()
14207
- });
14208
- DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
14209
- kind: "mutation",
14210
- auth: "admin"
14211
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14212
- kind: "mutation",
14213
- auth: "admin"
14214
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14215
- kind: "mutation",
14216
- auth: "admin"
14217
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14218
- kind: "mutation",
14219
- auth: "admin"
14220
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14221
- kind: "mutation",
14222
- auth: "admin"
14223
- }), method(object({
14224
- deviceId: number().int().nonnegative(),
14225
- speed: string().min(1)
14226
- }), _void(), {
14227
- kind: "mutation",
14228
- auth: "admin"
16350
+ cleanWater: TankStatusSchema.nullable(),
16351
+ /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
16352
+ dirtyWater: TankStatusSchema.nullable(),
16353
+ /** Detergent tank. Null when the hardware has no detergent tank. */
16354
+ detergent: TankStatusSchema.nullable(),
16355
+ /** Dust bin. Null when the hardware has no dust bin. */
16356
+ dustBin: TankStatusSchema.nullable(),
16357
+ /** 0..100 cleaning-completion percentage of the current task, or null. */
16358
+ progressPercent: number().min(0).max(100).nullable(),
16359
+ /** Current error code (0 / null = no error). */
16360
+ errorCode: number().nullable(),
16361
+ /** Human label for {@link errorCode}, or null when none / undecodable. */
16362
+ errorLabel: string().nullable(),
16363
+ /** Ms epoch when the slice was last updated. */
16364
+ lastChangedAt: number()
14229
16365
  });
14230
- object({
16366
+ var vacuumControlCapability = {
16367
+ name: "vacuum-control",
16368
+ scope: "device",
16369
+ deviceNative: true,
16370
+ mode: "singleton",
16371
+ deviceTypes: [DeviceType.Vacuum],
16372
+ methods: {
16373
+ start: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16374
+ kind: "mutation",
16375
+ auth: "admin"
16376
+ }),
16377
+ pause: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16378
+ kind: "mutation",
16379
+ auth: "admin"
16380
+ }),
16381
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16382
+ kind: "mutation",
16383
+ auth: "admin"
16384
+ }),
16385
+ returnToBase: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16386
+ kind: "mutation",
16387
+ auth: "admin"
16388
+ }),
16389
+ locate: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16390
+ kind: "mutation",
16391
+ auth: "admin"
16392
+ }),
16393
+ setFanSpeed: method(object({
16394
+ deviceId: number().int().nonnegative(),
16395
+ speed: string().min(1)
16396
+ }), _void(), {
16397
+ kind: "mutation",
16398
+ auth: "admin"
16399
+ })
16400
+ },
16401
+ status: {
16402
+ schema: VacuumControlStatusSchema,
16403
+ kind: "push"
16404
+ },
16405
+ /**
16406
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
16407
+ * slice for live state + battery + fan-speed changes.
16408
+ */
16409
+ runtimeState: VacuumControlStatusSchema
16410
+ };
16411
+ var ValveStatusSchema = object({
14231
16412
  /** Lifecycle state of the valve. */
14232
16413
  state: _enum([
14233
16414
  "open",
@@ -14242,29 +16423,80 @@ object({
14242
16423
  /** Ms epoch when the slice was last updated. */
14243
16424
  lastChangedAt: number()
14244
16425
  });
14245
- DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
14246
- kind: "mutation",
14247
- auth: "admin"
14248
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14249
- kind: "mutation",
14250
- auth: "admin"
14251
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
14252
- kind: "mutation",
14253
- auth: "admin"
14254
- }), method(object({
14255
- deviceId: number().int().nonnegative(),
14256
- position: number().min(0).max(100)
14257
- }), _void(), {
14258
- kind: "mutation",
14259
- auth: "admin"
14260
- });
14261
- object({
16426
+ var valveCapability = {
16427
+ name: "valve",
16428
+ scope: "device",
16429
+ deviceNative: true,
16430
+ mode: "singleton",
16431
+ deviceTypes: [DeviceType.Valve],
16432
+ methods: {
16433
+ open: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16434
+ kind: "mutation",
16435
+ auth: "admin"
16436
+ }),
16437
+ close: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16438
+ kind: "mutation",
16439
+ auth: "admin"
16440
+ }),
16441
+ stop: method(object({ deviceId: number().int().nonnegative() }), _void(), {
16442
+ kind: "mutation",
16443
+ auth: "admin"
16444
+ }),
16445
+ setPosition: method(object({
16446
+ deviceId: number().int().nonnegative(),
16447
+ position: number().min(0).max(100)
16448
+ }), _void(), {
16449
+ kind: "mutation",
16450
+ auth: "admin"
16451
+ })
16452
+ },
16453
+ status: {
16454
+ schema: ValveStatusSchema,
16455
+ kind: "push"
16456
+ },
16457
+ /**
16458
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
16459
+ * slice for live position changes during a move.
16460
+ */
16461
+ runtimeState: ValveStatusSchema
16462
+ };
16463
+ /**
16464
+ * Vibration / shake / impact sensor. Drives Home Assistant
16465
+ * `binary_sensor` entries with `device_class: vibration`. Push-driven.
16466
+ */
16467
+ var VibrationStatusSchema = object({
14262
16468
  detected: boolean(),
14263
16469
  /** Ms epoch of the last transition. 0 if never observed. */
14264
16470
  lastChangedAt: number()
14265
16471
  });
14266
- DeviceType.Sensor;
14267
- object({
16472
+ var vibrationCapability = {
16473
+ name: "vibration",
16474
+ scope: "device",
16475
+ deviceNative: true,
16476
+ mode: "singleton",
16477
+ deviceTypes: [DeviceType.Sensor],
16478
+ methods: {},
16479
+ status: {
16480
+ schema: VibrationStatusSchema,
16481
+ kind: "push"
16482
+ },
16483
+ runtimeState: VibrationStatusSchema
16484
+ };
16485
+ /**
16486
+ * Water heater / boiler cap. Models HA `water_heater.*` entities — a
16487
+ * climate-family actuator with a target temperature, an operation-mode
16488
+ * selector (`eco` / `electric` / `gas` / `heat_pump` / `high_demand` /
16489
+ * `performance` / `off`), and an optional away-mode toggle.
16490
+ *
16491
+ * The slice carries the current + target temperature, the active
16492
+ * operation mode (HA's `state`) and its available set (HA's
16493
+ * `operation_list`), the away flag (HA `away_mode` 'on'/'off' → bool,
16494
+ * null when unsupported), plus the `min_temp` / `max_temp` bounds.
16495
+ *
16496
+ * Providers populate only what the hardware reports — temperature and
16497
+ * away fields stay null when the device has no such surface.
16498
+ */
16499
+ var WaterHeaterStatusSchema = object({
14268
16500
  /** Current measured temperature. Null when not reported. */
14269
16501
  currentTemp: number().nullable(),
14270
16502
  /** Target temperature setpoint. Null when no setpoint surface. */
@@ -14285,26 +16517,65 @@ object({
14285
16517
  /** Ms epoch when the slice was last updated. */
14286
16518
  lastChangedAt: number()
14287
16519
  });
14288
- DeviceType.WaterHeater, method(object({
14289
- deviceId: number().int().nonnegative(),
14290
- temp: number().finite()
14291
- }), _void(), {
14292
- kind: "mutation",
14293
- auth: "admin"
14294
- }), method(object({
14295
- deviceId: number().int().nonnegative(),
14296
- mode: string().min(1)
14297
- }), _void(), {
14298
- kind: "mutation",
14299
- auth: "admin"
14300
- }), method(object({
14301
- deviceId: number().int().nonnegative(),
14302
- on: boolean()
14303
- }), _void(), {
14304
- kind: "mutation",
14305
- auth: "admin"
14306
- });
14307
- object({
16520
+ var waterHeaterCapability = {
16521
+ name: "water-heater",
16522
+ scope: "device",
16523
+ deviceNative: true,
16524
+ mode: "singleton",
16525
+ deviceTypes: [DeviceType.WaterHeater],
16526
+ methods: {
16527
+ setTargetTemp: method(object({
16528
+ deviceId: number().int().nonnegative(),
16529
+ temp: number().finite()
16530
+ }), _void(), {
16531
+ kind: "mutation",
16532
+ auth: "admin"
16533
+ }),
16534
+ setOperationMode: method(object({
16535
+ deviceId: number().int().nonnegative(),
16536
+ mode: string().min(1)
16537
+ }), _void(), {
16538
+ kind: "mutation",
16539
+ auth: "admin"
16540
+ }),
16541
+ setAway: method(object({
16542
+ deviceId: number().int().nonnegative(),
16543
+ on: boolean()
16544
+ }), _void(), {
16545
+ kind: "mutation",
16546
+ auth: "admin"
16547
+ })
16548
+ },
16549
+ status: {
16550
+ schema: WaterHeaterStatusSchema,
16551
+ kind: "push"
16552
+ },
16553
+ /**
16554
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
16555
+ * slice for live temperature / mode / away changes.
16556
+ */
16557
+ runtimeState: WaterHeaterStatusSchema
16558
+ };
16559
+ /**
16560
+ * Weather provider cap. Models HA `weather.*` entities — a read-only
16561
+ * snapshot of the CURRENT conditions a weather integration reports.
16562
+ *
16563
+ * Read-only: there are no setters. The slice is populated from upstream
16564
+ * pushes (the HA weather entity's state + attributes) and rendered by
16565
+ * the UI as a sky scene + readouts.
16566
+ *
16567
+ * `condition` is the verbatim HA state string (`sunny` / `cloudy` /
16568
+ * `rainy` / `snowy` / `partlycloudy` / `pouring` / `lightning` /
16569
+ * `lightning-rainy` / `fog` / `windy` / `windy-variant` / `hail` /
16570
+ * `clear-night` / `exceptional` / …). The UI maps it to a glyph + tint;
16571
+ * unknown strings fall back to a neutral cloud.
16572
+ *
16573
+ * Every numeric reading is nullable — a given weather integration only
16574
+ * populates the metrics it actually provides.
16575
+ *
16576
+ * Forecast deferred — current conditions only for v1.
16577
+ */
16578
+ var WeatherStatusSchema = object({
14308
16579
  /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
14309
16580
  * when no condition has been reported yet. */
14310
16581
  condition: string().nullable(),
@@ -14327,7 +16598,23 @@ object({
14327
16598
  /** Ms epoch when the slice was last updated. */
14328
16599
  lastFetchedAt: number()
14329
16600
  });
14330
- DeviceType.Weather;
16601
+ var weatherCapability = {
16602
+ name: "weather",
16603
+ scope: "device",
16604
+ deviceNative: true,
16605
+ mode: "singleton",
16606
+ deviceTypes: [DeviceType.Weather],
16607
+ methods: {},
16608
+ status: {
16609
+ schema: WeatherStatusSchema,
16610
+ kind: "push"
16611
+ },
16612
+ /**
16613
+ * Runtime-state slice — mirrored by the kernel. The UI reads the
16614
+ * current conditions directly from the slice on each weather push.
16615
+ */
16616
+ runtimeState: WeatherStatusSchema
16617
+ };
14331
16618
  /**
14332
16619
  * Per-zone occupancy aggregation produced by the analytics frame
14333
16620
  * processor on every inference result. Covers the full combinatorial
@@ -14590,21 +16877,604 @@ var ZoneRuleStageEnum = _enum([
14590
16877
  "detection",
14591
16878
  "package"
14592
16879
  ]);
14593
- DeviceType.Camera, method(object({
14594
- deviceId: number(),
14595
- stage: ZoneRuleStageEnum
14596
- }), array(ZoneRuleSchema).readonly()), method(object({
14597
- deviceId: number(),
14598
- stage: ZoneRuleStageEnum,
14599
- rules: array(ZoneRuleSchema).readonly()
14600
- }), _void(), {
14601
- kind: "mutation",
14602
- auth: "admin"
14603
- }), object({
14604
- motion: array(ZoneRuleSchema).readonly(),
14605
- detection: array(ZoneRuleSchema).readonly(),
14606
- package: array(ZoneRuleSchema).readonly()
14607
- });
16880
+ /**
16881
+ * Runtime registry: cap-property-name → cap definition. `BaseDevice`'s
16882
+ * `state` getter looks up the cap definition here to construct a
16883
+ * `sliceProxy()` lazily on first access. Generated alongside the type
16884
+ * so type and runtime registry can never drift apart.
16885
+ */
16886
+ var DEVICE_LOCAL_STATE_CAPS = {
16887
+ airQualitySensor: airQualitySensorCapability,
16888
+ alarmPanel: alarmPanelCapability,
16889
+ ambientLightSensor: ambientLightSensorCapability,
16890
+ audioMetrics: audioMetricsCapability,
16891
+ automationControl: automationControlCapability,
16892
+ battery: batteryCapability,
16893
+ binary: binaryCapability,
16894
+ brightness: brightnessCapability,
16895
+ cameraStreams: cameraStreamsCapability,
16896
+ carbonMonoxide: carbonMonoxideCapability,
16897
+ climateControl: climateControlCapability,
16898
+ color: colorCapability,
16899
+ connectivity: connectivityCapability,
16900
+ consumables: consumablesCapability,
16901
+ contact: contactCapability,
16902
+ control: controlCapability,
16903
+ cover: coverCapability,
16904
+ dayNight: dayNightCapability,
16905
+ deviceDiscovery: deviceDiscoveryCapability,
16906
+ deviceStatus: deviceStatusCapability,
16907
+ doorbell: doorbellCapability,
16908
+ enumSensor: enumSensorCapability,
16909
+ eventEmitter: eventEmitterCapability,
16910
+ fanControl: fanControlCapability,
16911
+ featureProbe: featureProbeCapability,
16912
+ flood: floodCapability,
16913
+ gas: gasCapability,
16914
+ humidifier: humidifierCapability,
16915
+ humiditySensor: humiditySensorCapability,
16916
+ image: imageCapability,
16917
+ imageSettings: imageSettingsCapability,
16918
+ lawnMowerControl: lawnMowerControlCapability,
16919
+ lockControl: lockControlCapability,
16920
+ mediaPlayer: mediaPlayerCapability,
16921
+ motion: motionCapability,
16922
+ motionTrigger: motionTriggerCapability,
16923
+ motionZones: motionZonesCapability,
16924
+ nativeObjectDetection: nativeObjectDetectionCapability,
16925
+ notifier: notifierCapability,
16926
+ numericSensor: numericSensorCapability,
16927
+ petFeeder: petFeederCapability,
16928
+ powerMeter: powerMeterCapability,
16929
+ presence: presenceCapability,
16930
+ pressureSensor: pressureSensorCapability,
16931
+ privacyMask: privacyMaskCapability,
16932
+ ptzAutotrack: ptzAutotrackCapability,
16933
+ sceneMonitor: sceneMonitorCapability,
16934
+ scriptRunner: scriptRunnerCapability,
16935
+ smoke: smokeCapability,
16936
+ streamParams: streamParamsCapability,
16937
+ switch: switchCapability,
16938
+ tamper: tamperCapability,
16939
+ temperatureSensor: temperatureSensorCapability,
16940
+ update: updateCapability,
16941
+ vacuumControl: vacuumControlCapability,
16942
+ valve: valveCapability,
16943
+ vibration: vibrationCapability,
16944
+ waterHeater: waterHeaterCapability,
16945
+ weather: weatherCapability,
16946
+ zoneAnalytics: zoneAnalyticsCapability,
16947
+ zoneRules: {
16948
+ name: "zone-rules",
16949
+ scope: "device",
16950
+ mode: "singleton",
16951
+ deviceTypes: [DeviceType.Camera],
16952
+ methods: {
16953
+ /** Read the full rule list for a given stage (empty when no rules
16954
+ * are defined yet). */
16955
+ listRules: method(object({
16956
+ deviceId: number(),
16957
+ stage: ZoneRuleStageEnum
16958
+ }), array(ZoneRuleSchema).readonly()),
16959
+ /** Bulk-replace the rule list for one stage. The provider validates
16960
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
16961
+ * thresholds in range) and rejects the whole patch if any entry
16962
+ * is invalid — partial writes are a configuration footgun. */
16963
+ setRules: method(object({
16964
+ deviceId: number(),
16965
+ stage: ZoneRuleStageEnum,
16966
+ rules: array(ZoneRuleSchema).readonly()
16967
+ }), _void(), {
16968
+ kind: "mutation",
16969
+ auth: "admin"
16970
+ })
16971
+ },
16972
+ /**
16973
+ * Runtime-state slice — every stage mirrored together so consumers
16974
+ * see one reactive handle (`device.state.zoneRules.value`) instead
16975
+ * of one per stage. Bulk-replace mutations on any stage write the full
16976
+ * `{motion, detection, package}` shape, so subscribers always get the
16977
+ * complete current set. Consumers that only care about one stage
16978
+ * just read the matching property.
16979
+ *
16980
+ * `package` backs the package-drop detector — a package zone is a
16981
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
16982
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
16983
+ * The orchestrator provider writes this stage as a first-class slice
16984
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
16985
+ * package}` shape, so consumers read the current package rules directly
16986
+ * off `device.state.zoneRules.value.package`.
16987
+ */
16988
+ runtimeState: object({
16989
+ motion: array(ZoneRuleSchema).readonly(),
16990
+ detection: array(ZoneRuleSchema).readonly(),
16991
+ package: array(ZoneRuleSchema).readonly()
16992
+ })
16993
+ },
16994
+ zones: zonesCapability
16995
+ };
16996
+ var BaseDevice = class {
16997
+ id;
16998
+ stableId;
16999
+ type;
17000
+ name;
17001
+ parentDeviceId;
17002
+ role;
17003
+ /**
17004
+ * Cap-keyed runtime-state slice is the single source of truth for
17005
+ * `online`. Both getter and setter proxy to the slice — drivers can
17006
+ * write `this.online = true` ergonomically, and the cap event fires
17007
+ * automatically through the runtime-state writer. `markOnline()` is
17008
+ * kept as the explicit method form mandated by `IDevice`.
17009
+ */
17010
+ get online() {
17011
+ return this.runtimeState.getCapState("device-status")?.online ?? false;
17012
+ }
17013
+ set online(value) {
17014
+ this.markOnline(value);
17015
+ }
17016
+ /**
17017
+ * Generic per-cap runtime-state namespace. One entry per cap with
17018
+ * `runtimeState:` declared, auto-generated by codegen — see
17019
+ * `device-local-state.ts`. Drivers access via:
17020
+ *
17021
+ * `this.state.battery.sleeping = true` // patches the battery slice
17022
+ * `const pct = this.state.battery.percentage` // reads the battery slice
17023
+ * `this.state.deviceStatus.online = true` // mirrors `markOnline(true)`
17024
+ *
17025
+ * Adding a new cap with `runtimeState:` automatically extends this
17026
+ * namespace — drivers don't have to declare proxies. Reads return
17027
+ * `undefined` when the slice hasn't been seeded; writes patch via
17028
+ * `runtimeState.patchCapState` and validate against the cap's schema
17029
+ * (so partial writes need the slice to be seeded with the required
17030
+ * fields first — drivers do this on cap registration).
17031
+ *
17032
+ * For caps not exposed in `DeviceLocalState`, drivers can build their
17033
+ * own typed proxy via `this.sliceProxy(cap)`.
17034
+ */
17035
+ get state() {
17036
+ if (!this._stateProxyCache) {
17037
+ const cache = {};
17038
+ const handler = { get: (_target, key) => {
17039
+ const k = key;
17040
+ if (k in cache) return cache[k];
17041
+ const cap = DEVICE_LOCAL_STATE_CAPS[k];
17042
+ if (!cap) return void 0;
17043
+ const proxy = this.sliceProxy(cap);
17044
+ cache[k] = proxy;
17045
+ return proxy;
17046
+ } };
17047
+ this._stateProxyCache = new Proxy(cache, handler);
17048
+ }
17049
+ return this._stateProxyCache;
17050
+ }
17051
+ _stateProxyCache;
17052
+ config;
17053
+ /**
17054
+ * Per-device runtime state, cap-keyed. Always installed — slices
17055
+ * for individual caps materialise as those caps register their
17056
+ * native providers (`ctx.registerNativeCap`). The cap's own
17057
+ * `runtimeState` schema is the source of truth for the slice
17058
+ * shape; drivers don't redeclare it, they just write through.
17059
+ *
17060
+ * Read: `this.runtimeState.getCapState('battery')` →
17061
+ * `{percentage, charging, sleeping, lastUpdated}` for any
17062
+ * provider that registers `batteryCapability`.
17063
+ * Write: `this.runtimeState.setCapState('battery', { … })`.
17064
+ *
17065
+ * Cross-process consumers reach this state through the
17066
+ * `deviceState` cap router (or via cap-specific events the driver
17067
+ * emits — e.g. `battery.onStatusChanged`). The local handle is
17068
+ * accessed in-process by the driver to avoid roundtrips.
17069
+ */
17070
+ runtimeState;
17071
+ ctx;
17072
+ /**
17073
+ * Operator-organisational location label (room / area / zone).
17074
+ * Read from `ctx.deviceMeta.location`; mutated via
17075
+ * `kernel.devices.setLocation(id, value)`. Free-text — providers
17076
+ * don't interpret it; the UI groups devices by this for filters
17077
+ * like "show me all cameras in Kitchen". `null` when unset.
17078
+ */
17079
+ location;
17080
+ /**
17081
+ * Soft-disabled flag. When `true`, the device class is still
17082
+ * instantiated and visible in the UI (so the operator can flip
17083
+ * back on without re-adding) but lifecycle hooks (publishToBroker,
17084
+ * alarm-stream subscribe, …) MUST be gated by the driver to skip
17085
+ * work. The `BaseDevice` enforces this by exposing the flag here;
17086
+ * it does NOT mutate cap behaviour automatically — drivers consult
17087
+ * `this.disabled` at the top of their lifecycle methods. Read from
17088
+ * `ctx.deviceMeta.disabled`; mutated via
17089
+ * `kernel.devices.setDisabled(id, value)`.
17090
+ */
17091
+ disabled;
17092
+ /**
17093
+ * Cached materialised `SourceInfo` — either the value persisted under
17094
+ * `metadata.sourceInfo` at construction time, or a synthetic
17095
+ * `{ id: stableId, system: addonId }` for providers that haven't
17096
+ * migrated yet. Lazily populated on first `sourceInfo` read so the
17097
+ * cost of Zod-parsing the meta blob is paid once per device boot.
17098
+ * Invalidated by `updateSourceInfo()` so providers see the new value
17099
+ * back through the getter without a re-fetch from the meta surface.
17100
+ */
17101
+ _sourceInfoCache = null;
17102
+ constructor(ctx, schema, options) {
17103
+ this.ctx = ctx;
17104
+ this.id = ctx.id;
17105
+ this.stableId = ctx.stableId;
17106
+ this.type = options.type;
17107
+ if (!ctx.deviceMeta) throw new Error(`BaseDevice constructor: ctx.deviceMeta is required (id=${ctx.id} stableId=${ctx.stableId})`);
17108
+ this.name = ctx.deviceMeta.name;
17109
+ this.location = ctx.deviceMeta.location;
17110
+ this.disabled = ctx.deviceMeta.disabled;
17111
+ this.role = options.role;
17112
+ this.parentDeviceId = ctx.parentDeviceId;
17113
+ const seedData = ctx.persistedConfig ?? {};
17114
+ this.config = DeviceConfig.fromSchema(schema, (data) => ctx.persistConfig(data), seedData, ({ droppedKeys, issues }) => {
17115
+ ctx.logger.warn("Device config recovery: dropping invalid persisted fields", {
17116
+ tags: {
17117
+ deviceId: ctx.id,
17118
+ stableId: ctx.stableId
17119
+ },
17120
+ meta: {
17121
+ droppedKeys: [...droppedKeys],
17122
+ firstIssue: issues[0]?.message ?? null
17123
+ }
17124
+ });
17125
+ });
17126
+ let cachedProxy = null;
17127
+ const writer = async (capName, slice) => {
17128
+ if (!cachedProxy) cachedProxy = ctx.fetchDevice(ctx.id);
17129
+ await (await cachedProxy).deviceState.setCapSlice({
17130
+ capName,
17131
+ slice
17132
+ });
17133
+ };
17134
+ const initial = ctx.initialRuntimeState ?? {};
17135
+ this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer);
17136
+ ctx.bindRuntimeState?.(this.runtimeState);
17137
+ ctx.registerNativeCap?.(deviceStatusCapability, {});
17138
+ const seed = {
17139
+ online: true,
17140
+ lastChangedAt: Date.now()
17141
+ };
17142
+ this.runtimeState.setCapState("device-status", seed);
17143
+ ctx.registerNativeCap?.(featureProbeCapability, {});
17144
+ this.runtimeState.setCapState("feature-probe", {
17145
+ flags: {},
17146
+ deviceType: null,
17147
+ model: null,
17148
+ channelCount: null,
17149
+ lastProbedAt: 0,
17150
+ lastFetchedAt: 0
17151
+ });
17152
+ }
17153
+ deviceActions = /* @__PURE__ */ new Map();
17154
+ /** Declare a device custom action + its typed handler. Idempotent per name. */
17155
+ registerDeviceAction(name, spec, handler) {
17156
+ this.deviceActions.set(name, {
17157
+ spec,
17158
+ handler
17159
+ });
17160
+ }
17161
+ /** Invoke a registered device action. Validates input against the spec. */
17162
+ async runDeviceAction(action, input) {
17163
+ const entry = this.deviceActions.get(action);
17164
+ if (!entry) throw new Error(`unknown device action "${action}" on device ${this.id}`);
17165
+ const parsed = entry.spec.input.parse(input);
17166
+ return entry.handler(parsed);
17167
+ }
17168
+ async removeDevice() {}
17169
+ /**
17170
+ * Set the device's online flag. Called by `BaseDeviceProvider` after
17171
+ * aggregating per-profile stream-broker health, or directly by drivers
17172
+ * that have provider-side liveness signals (e.g. ONVIF heartbeats,
17173
+ * Reolink Baichuan firmware push events). Mirrors the new value into
17174
+ * the `device-status` runtime-state slice so cross-process consumers
17175
+ * pick it up via the standard cap-state channel. Subclasses can
17176
+ * override to gate side effects on the transition.
17177
+ */
17178
+ markOnline(online) {
17179
+ if (this.online === online) return;
17180
+ const next = {
17181
+ online,
17182
+ lastChangedAt: Date.now()
17183
+ };
17184
+ this.runtimeState.setCapState("device-status", next);
17185
+ }
17186
+ /**
17187
+ * Upstream-system identity + rendering envelope for this device. See
17188
+ * `SourceInfo` for the field contract. Always returns a valid object:
17189
+ * if the persisted `metadata.sourceInfo` blob is absent or fails Zod
17190
+ * validation, falls back to a synthetic `{ id: stableId, system: addonId }`
17191
+ * so providers that haven't migrated keep working without code changes.
17192
+ *
17193
+ * The value is cached after the first read. `updateSourceInfo()`
17194
+ * invalidates the cache so subsequent reads see the new patch. The
17195
+ * returned object is frozen to prevent accidental in-place mutation —
17196
+ * use `updateSourceInfo({ patch })` to change fields.
17197
+ */
17198
+ get sourceInfo() {
17199
+ if (this._sourceInfoCache) return this._sourceInfoCache;
17200
+ const resolved = extractSourceInfoFromMetadata(this.ctx.deviceMeta.metadata) ?? synthesizeSourceInfo({
17201
+ stableId: this.stableId,
17202
+ addonId: this.ctx.deviceMeta.addonId
17203
+ });
17204
+ this._sourceInfoCache = Object.freeze({ ...resolved });
17205
+ return this._sourceInfoCache;
17206
+ }
17207
+ /**
17208
+ * Convenience accessor for the upstream dispatch key. Equivalent to
17209
+ * `this.sourceInfo.id` — providers use this to keep a
17210
+ * `Map<sourceId, IDevice>` for routing inbound push events.
17211
+ */
17212
+ get sourceId() {
17213
+ return this.sourceInfo.id;
17214
+ }
17215
+ /**
17216
+ * Patch the device's `SourceInfo`. Shallow-merges `patch` over the
17217
+ * current value, persists the merged result under
17218
+ * `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
17219
+ * emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
17220
+ *
17221
+ * Safe to call from anywhere in the device's lifetime — the call is
17222
+ * idempotent for `undefined` patch values (ignored) and best-effort
17223
+ * for persistence (a transient device-manager error doesn't unwind
17224
+ * the local cache update, so subsequent reads still see the patch).
17225
+ *
17226
+ * Drivers populate this on adoption + on every metadata change push
17227
+ * from the upstream source. Subscribers (UI, export adapters) react
17228
+ * via the `DeviceSourceInfoChanged` event without polling.
17229
+ */
17230
+ async updateSourceInfo(patch) {
17231
+ const next = mergeSourceInfo(this.sourceInfo, patch);
17232
+ this._sourceInfoCache = Object.freeze({ ...next });
17233
+ const action = this.ctx.api?.deviceManager?.setMetadata;
17234
+ if (action) try {
17235
+ await action.mutate({
17236
+ deviceId: this.id,
17237
+ patch: { [SOURCE_INFO_METADATA_KEY]: next }
17238
+ });
17239
+ } catch {}
17240
+ this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
17241
+ type: "device",
17242
+ id: this.stableId
17243
+ }, {
17244
+ deviceId: this.id,
17245
+ sourceInfo: next
17246
+ }));
17247
+ }
17248
+ /**
17249
+ * Re-publish the device's current `features` array to the persisted
17250
+ * meta blob. Drivers call this after a probe finishes when the live
17251
+ * `features` getter has gained new flags (e.g. `hasIntercom` flips
17252
+ * to true → `DeviceFeature.TwoWayAudio` joins the list).
17253
+ *
17254
+ * Without this, only the construction-time snapshot is written —
17255
+ * `deviceManager.registerDevice` is invoked once per boot, so probe-
17256
+ * driven additions don't reach the persisted index until the next
17257
+ * server restart, and `getDevice` / `listAll` keep returning the
17258
+ * stale list for forked-worker devices (whose live IDevice instance
17259
+ * is invisible to the hub registry).
17260
+ *
17261
+ * Idempotent: re-calling with the same features just no-ops on the
17262
+ * persisted meta. Best-effort: lookup or write failures are logged
17263
+ * at debug and swallowed — the live `device.features` getter is
17264
+ * still authoritative within this process, so callers never block
17265
+ * device boot on a meta refresh.
17266
+ */
17267
+ async refreshFeatures() {
17268
+ const action = this.ctx.api?.deviceManager?.registerDevice;
17269
+ if (!action) return;
17270
+ try {
17271
+ await action.mutate({
17272
+ addonId: this.ctx.deviceMeta.addonId,
17273
+ stableId: this.stableId,
17274
+ id: this.id,
17275
+ type: this.type,
17276
+ name: this.name,
17277
+ parentDeviceId: this.parentDeviceId,
17278
+ features: [...this.features],
17279
+ config: {}
17280
+ });
17281
+ } catch (err) {}
17282
+ }
17283
+ /**
17284
+ * Typed read-through to a cap-keyed runtime-state slice. Drivers
17285
+ * call `this.getCapSlice(batteryCapability)` and the return type
17286
+ * is inferred from the cap's `runtimeState` Zod schema — no string
17287
+ * key, no manual generic. Returns `null` when the slice hasn't
17288
+ * been written yet (e.g. driver hasn't seeded battery yet).
17289
+ */
17290
+ getCapSlice(cap) {
17291
+ return this.runtimeState.getCapState(cap.name) ?? null;
17292
+ }
17293
+ /**
17294
+ * Typed writer to a cap-keyed runtime-state slice. Routes through
17295
+ * the runtime-state writer (validate → persist → emit cap event).
17296
+ * Equivalent to `this.runtimeState.setCapState(cap.name, value)`
17297
+ * but with the cap's `runtimeState` schema enforcing the value
17298
+ * shape at compile time. Mirrors the symmetry of
17299
+ * `getCapSlice` / `setCapSlice` for cross-cap consistency.
17300
+ */
17301
+ setCapSlice(cap, value) {
17302
+ this.runtimeState.setCapState(cap.name, value);
17303
+ }
17304
+ /**
17305
+ * Field-level read/write proxy over a cap's runtime-state slice.
17306
+ * Drivers that want ergonomic per-field access declare:
17307
+ *
17308
+ * ```ts
17309
+ * protected battery = this.sliceProxy(batteryCapability)
17310
+ * // …
17311
+ * this.battery.sleeping = true // patches the slice
17312
+ * const charging = this.battery.charging // reads the slice
17313
+ * ```
17314
+ *
17315
+ * Reads return `undefined` when the slice hasn't been seeded yet
17316
+ * (cap not registered, or seeded but the field is absent). Writes
17317
+ * route through `runtimeState.patchCapState` so the cap's `runtimeState`
17318
+ * schema validates the merged result and the cap event fires.
17319
+ *
17320
+ * Pattern is generic — same shape works for `battery`, `device-status`,
17321
+ * `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
17322
+ * declare one proxy per cap they read/write directly.
17323
+ */
17324
+ sliceProxy(cap) {
17325
+ return new Proxy({}, {
17326
+ get: (_, key) => {
17327
+ return this.runtimeState.getCapState(cap.name)?.[key];
17328
+ },
17329
+ set: (_, key, value) => {
17330
+ this.runtimeState.patchCapState(cap.name, { [key]: value });
17331
+ return true;
17332
+ },
17333
+ has: (_, key) => {
17334
+ const slice = this.runtimeState.getCapState(cap.name);
17335
+ return slice ? key in slice : false;
17336
+ },
17337
+ ownKeys: () => {
17338
+ const slice = this.runtimeState.getCapState(cap.name);
17339
+ return slice ? Object.keys(slice) : [];
17340
+ },
17341
+ getOwnPropertyDescriptor: (_, key) => {
17342
+ const slice = this.runtimeState.getCapState(cap.name);
17343
+ if (!slice || !(key in slice)) return void 0;
17344
+ return {
17345
+ configurable: true,
17346
+ enumerable: true,
17347
+ value: slice[key]
17348
+ };
17349
+ }
17350
+ });
17351
+ }
17352
+ /**
17353
+ * Default empty settings UI. Drivers override this to expose an
17354
+ * editable form in the device-details page. Returning an empty sections
17355
+ * array signals "nothing to contribute" — the aggregator drops the
17356
+ * contribution entirely rather than rendering a blank panel.
17357
+ */
17358
+ getSettingsUISchema() {
17359
+ return { sections: [] };
17360
+ }
17361
+ /**
17362
+ * Default write path: forward the flat patch directly to storage.
17363
+ * Drivers that project a UI shape different from storage (e.g. `RtspCamera`
17364
+ * exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
17365
+ * to reshape before `config.setAll`.
17366
+ */
17367
+ async applySettingsPatch(patch) {
17368
+ await this.config.setAll(patch);
17369
+ }
17370
+ /**
17371
+ * Phase 3 — populate device-scoped state needed by downstream phases
17372
+ * (accessory reconciliation, public `features` array, optional cap
17373
+ * registration). Called ONCE per construction, after register but
17374
+ * before `getAccessoryChildren()`.
17375
+ *
17376
+ * Drivers write the `feature-probe` runtime-state slice via
17377
+ * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
17378
+ * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
17379
+ * `hasSupplementalLight/hasAlarmIo`, etc).
17380
+ *
17381
+ * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
17382
+ * the kernel treats it as ready immediately. A device that derives its shape
17383
+ * from a spec (a container, or an accessory sensor) rather than from a
17384
+ * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
17385
+ * it would look perpetually un-probed — logging "Initial probe did not
17386
+ * complete" on every boot and spinning a pointless retry chain. Drivers that
17387
+ * DO probe override this and write their own `feature-probe` slice (including
17388
+ * `lastProbedAt`) once their probe actually succeeds.
17389
+ */
17390
+ async onProbe() {
17391
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
17392
+ flags: {},
17393
+ deviceType: null,
17394
+ model: null,
17395
+ channelCount: null,
17396
+ lastProbedAt: 0,
17397
+ lastFetchedAt: 0
17398
+ };
17399
+ this.runtimeState.setCapState("feature-probe", {
17400
+ ...base,
17401
+ lastProbedAt: Date.now()
17402
+ });
17403
+ }
17404
+ /**
17405
+ * Phase 5 — fired after the device + its accessories are registered.
17406
+ * Drivers publish streams to the broker, kick off background tasks,
17407
+ * or subscribe to lib events that need a fully-registered device id.
17408
+ *
17409
+ * Default: no-op.
17410
+ *
17411
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
17412
+ * pass). The new name reflects the post-probe, post-accessory contract.
17413
+ */
17414
+ async onActivate() {}
17415
+ /**
17416
+ * Re-run the probe + reconcile accessories + refresh features meta.
17417
+ * Drivers call this when device-side state changes (battery cam wakes,
17418
+ * firmware update, manual operator trigger).
17419
+ *
17420
+ * The kernel injects `_kernelReprobe` on registration so this method
17421
+ * delegates to the same orchestrator that runs the boot-time phase
17422
+ * 3 + 4 sequence. Drivers should NOT override this — they override
17423
+ * `onProbe()` instead.
17424
+ */
17425
+ async reprobe() {
17426
+ if (this._kernelReprobe) await this._kernelReprobe();
17427
+ else await this.onProbe();
17428
+ }
17429
+ /**
17430
+ * Kernel-injected callback that runs the full post-probe orchestration
17431
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
17432
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
17433
+ * directly — call `reprobe()` instead.
17434
+ */
17435
+ _kernelReprobe;
17436
+ /**
17437
+ * Declare accessory child devices the kernel should auto-spawn
17438
+ * after `onProbe()` resolves. Each spec fully describes one child
17439
+ * — stableId suffix (deterministic per kind for restore-safety),
17440
+ * meta (type / name / location), config (initial blob the child
17441
+ * self-hydrates), and a factory that constructs the concrete
17442
+ * class with whatever closure-captured refs it needs (typically
17443
+ * `this` for the parent reference).
17444
+ *
17445
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
17446
+ * (skipped on restore when the row already exists),
17447
+ * persistInitialMeta, createContext, factory invocation, register,
17448
+ * and recursive lifecycle (probe + accessories + activate).
17449
+ *
17450
+ * Implementations should derive children from
17451
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
17452
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
17453
+ * with a typed cast.
17454
+ *
17455
+ * Default: no children.
17456
+ */
17457
+ getAccessoryChildren() {
17458
+ return [];
17459
+ }
17460
+ /**
17461
+ * Read the current feature-probe flag bag with a typed cast. Helper
17462
+ * for `getAccessoryChildren()` and `features` getters that derive
17463
+ * outputs from the probe results.
17464
+ */
17465
+ getProbeFlags() {
17466
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
17467
+ }
17468
+ /**
17469
+ * Returns true once `onProbe` has completed at least once
17470
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
17471
+ * to avoid spawning stale accessories on a fresh device whose probe
17472
+ * hasn't landed yet.
17473
+ */
17474
+ hasProbed() {
17475
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
17476
+ }
17477
+ };
14608
17478
  var ProviderStatusSchema = object({
14609
17479
  connected: boolean(),
14610
17480
  deviceCount: number(),
@@ -15688,6 +18558,10 @@ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, Broke
15688
18558
  auth: "admin"
15689
18559
  }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
15690
18560
  DeviceType.Camera;
18561
+ /** kebab-case cap name → camelCase router-map key. */
18562
+ function kebabToCamel(s) {
18563
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
18564
+ }
15691
18565
  /**
15692
18566
  * Identity — preserves literal types for downstream inference.
15693
18567
  *
@@ -27741,4 +30615,4 @@ Object.freeze({
27741
30615
  "smtp-provider": "email"
27742
30616
  });
27743
30617
  //#endregion
27744
- export { subKindsOf as A, array as B, defineCustomActions as C, notificationRulesCapability as D, hfModelUrl as E, DeviceType as F, string as G, literal as H, createEvent as I, EventCategory as K, hydrateSchema as L, zoneAnalyticsCapability as M, errMsg as N, pipelineAnalyticsCapability as O, BaseAddon as P, nodePin as R, customAction as S, faceGalleryCapability as T, number as U, boolean as V, object as W, TimelapseRuleSchema as _, NC_CONDITION_CATALOG as a, buildEventKindDescriptor as b, NcRuleInputSchema as c, NcSnoozeInputSchema as d, NcSnoozeSchema as f, TimelapseRuleInputSchema as g, OpsLogEntrySchema as h, MACRO_LABELS as i, videoclipsCapability as j, plateGalleryCapability as k, NcRulePatchSchema as l, NcTaxonomySchema as m, EVENT_KIND_BY_CAP as n, NC_TAXONOMY as o, NcSnoozeSuppressedSchema as p, EVENT_PAD_MS as r, NcConditionDescriptorSchema as s, DEFAULT_EVENT_COLOR as t, NcRuleSchema as u, addonWidgetsSourceCapability as v, embeddingEncoderCapability as w, cosineSimilarity as x, audioMetricsCapability as y, _enum as z };
30618
+ export { EventCategory as $, notificationRulesCapability as A, createEvent as B, cosineSimilarity as C, faceGalleryCapability as D, embeddingEncoderCapability as E, videoclipsCapability as F, array as G, isDeviceScopedCap as H, zoneAnalyticsCapability as I, number as J, boolean as K, errMsg as L, plateGalleryCapability as M, readDeviceStateFrom as N, hfModelUrl as O, subKindsOf as P, unknown as Q, BaseAddon as R, buildEventKindDescriptor as S, defineCustomActions as T, nodePin as U, hydrateSchema as V, _enum as W, record as X, object as Y, string as Z, TimelapseRuleInputSchema as _, MACRO_LABELS as a, alarmPanelCapability as b, NcConditionDescriptorSchema as c, NcRuleSchema as d, NcSnoozeInputSchema as f, OpsLogEntrySchema as g, NcTaxonomySchema as h, EVENT_PAD_MS as i, pipelineAnalyticsCapability as j, kebabToCamel as k, NcRuleInputSchema as l, NcSnoozeSuppressedSchema as m, DEFAULT_EVENT_COLOR as n, NC_CONDITION_CATALOG as o, NcSnoozeSchema as p, literal as q, EVENT_KIND_BY_CAP as r, NC_TAXONOMY as s, BaseDevice as t, NcRulePatchSchema as u, TimelapseRuleSchema as v, customAction as w, audioMetricsCapability as x, addonWidgetsSourceCapability as y, DeviceType as z };