@camstack/addon-provider-enpal 0.1.15 → 0.1.17

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.
package/dist/addon.js CHANGED
@@ -1,4 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_child_process = require("node:child_process");
3
+ let node_fs = require("node:fs");
4
+ let node_path = require("node:path");
5
+ let node_url = require("node:url");
2
6
  //#region ../types/dist/event-category-78tcBemo.mjs
3
7
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4
8
  EventCategory["SystemBoot"] = "system.boot";
@@ -802,7 +806,7 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
802
806
  return EventCategory;
803
807
  }({});
804
808
  //#endregion
805
- //#region ../types/dist/composed-stable-id-jnvhwGoH.mjs
809
+ //#region ../types/dist/composed-stable-id-CPp3votu.mjs
806
810
  /**
807
811
  import { errMsg } from '@camstack/types'
808
812
  * Extract a human-readable message from an unknown error value.
@@ -815,17 +819,18 @@ function errMsg(err) {
815
819
  }
816
820
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
817
821
  DeviceType["Camera"] = "camera";
822
+ /** Legacy stored value, read only by the hub-to-integration migrations; no live device carries it. */
818
823
  DeviceType["Hub"] = "hub";
819
824
  DeviceType["Light"] = "light";
820
825
  DeviceType["Siren"] = "siren";
821
826
  DeviceType["Switch"] = "switch";
822
827
  DeviceType["Sensor"] = "sensor";
823
- DeviceType["Thermostat"] = "thermostat";
824
- /** Air-conditioner / heat-pump climate device (HVAC) — shares the
825
- * `climate-control` cap surface with `Thermostat` but renders a
826
- * dedicated AC-appropriate control UI (mode chips, fan speed,
827
- * independent vertical/horizontal swing). Sources: native Gree, and
828
- * reusable by other AC integrations. */
828
+ /** Every climate device: an air conditioner, a room thermostat, a radiator
829
+ * valve, a floor-heating zone, a heat-pump circuit. They share one set of
830
+ * controls (`climate-control`), so they are one type; WHICH kind of climate
831
+ * device it is is its {@link DeviceRole} (`climate-*`), which the UI and the
832
+ * exports branch on. The retired `thermostat` type is read as this one
833
+ * (`canonicalStoredDeviceType`). */
829
834
  DeviceType["Climate"] = "climate";
830
835
  DeviceType["Button"] = "button";
831
836
  /** Generic stateless event emitter — carries a device's EXACT declared
@@ -916,10 +921,6 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
916
921
  /** Washing machine. A plug-built one is running while its power stays
917
922
  * above a threshold. No program cap — that surface is the dishwasher's. */
918
923
  DeviceType["Washer"] = "washer";
919
- /** A dumb plug-in stove or heater: a switch (and, with a meter, a power
920
- * reading) that only runs while the plug is on. Built from a smart plug;
921
- * the `timed-run` cap switches it off again after a chosen time. */
922
- DeviceType["Heater"] = "heater";
923
924
  /** Tumble dryer. Same plug-built running surface as {@link Washer}. */
924
925
  DeviceType["Dryer"] = "dryer";
925
926
  /** The physical bridge box of a vendor ecosystem (Velux KLF-200, and later
@@ -1139,6 +1140,27 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
1139
1140
  /** A smart plug: a `switch` whose source says it is an outlet (HA
1140
1141
  * `device_class: outlet`). The Alexa export shows it as a SMARTPLUG. */
1141
1142
  DeviceRole["Outlet"] = "outlet";
1143
+ /** A dumb plug-in stove or heater behind a switch: it runs only while the
1144
+ * switch is on. Offered `timed-run`, which switches it off again after a
1145
+ * chosen time. */
1146
+ DeviceRole["SwitchHeater"] = "switch-heater";
1147
+ /** A plain fan behind a switch (a smart plug powering it): drawn as a fan,
1148
+ * with the switch's on/off only. Speed or oscillation appear only when the
1149
+ * device also has a fan cap (D1033). */
1150
+ DeviceRole["SwitchFan"] = "switch-fan";
1151
+ /** Air conditioner / split unit: cooling, fan speed, louvres. */
1152
+ DeviceRole["ClimateAc"] = "climate-ac";
1153
+ /** Room thermostat: a setpoint and a room temperature. The default. */
1154
+ DeviceRole["ClimateThermostat"] = "climate-thermostat";
1155
+ /** Floor-heating zone actuator. */
1156
+ DeviceRole["ClimateFloor"] = "climate-floor";
1157
+ /** Radiator valve (thermostatic radiator head). */
1158
+ DeviceRole["ClimateRadiator"] = "climate-radiator";
1159
+ /** A heater with its own thermostat (stove, convector). */
1160
+ DeviceRole["ClimateHeater"] = "climate-heater";
1161
+ /** One heating/cooling circuit of a heat pump: a water loop with comfort /
1162
+ * eco setpoints and a heat/cool mode, no room sensor. */
1163
+ DeviceRole["ClimateHeatPumpCircuit"] = "climate-heat-pump-circuit";
1142
1164
  /** Generic motion / occupancy / moving event source. Distinct from
1143
1165
  * the camera accessory PirSensor role: that one is a camera child;
1144
1166
  * this is a standalone HA / 3rd-party motion sensor. */
@@ -6026,7 +6048,7 @@ var ZodIssueCode = {
6026
6048
  var ZodFirstPartyTypeKind;
6027
6049
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
6028
6050
  //#endregion
6029
- //#region ../types/dist/sleep-Dz1P6--1.mjs
6051
+ //#region ../types/dist/sleep-D0roI_CZ.mjs
6030
6052
  /**
6031
6053
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6032
6054
  * window to float samples (D455).
@@ -11803,6 +11825,50 @@ var ACTUATOR_CAP_VOCABULARY = {
11803
11825
  value: false
11804
11826
  },
11805
11827
  args: { oscillating: false }
11828
+ },
11829
+ {
11830
+ id: "fan.horizontalOscillateOn",
11831
+ method: "setOscillationAxis",
11832
+ label: t("Horizontal oscillation on", "Oscillazione orizzontale attiva"),
11833
+ phrase: t("Start the horizontal oscillation of {device}", "Attiva l’oscillazione orizzontale di {device}"),
11834
+ icon: "move-horizontal",
11835
+ args: {
11836
+ axis: "horizontal",
11837
+ oscillating: true
11838
+ }
11839
+ },
11840
+ {
11841
+ id: "fan.horizontalOscillateOff",
11842
+ method: "setOscillationAxis",
11843
+ label: t("Horizontal oscillation off", "Oscillazione orizzontale spenta"),
11844
+ phrase: t("Stop the horizontal oscillation of {device}", "Ferma l’oscillazione orizzontale di {device}"),
11845
+ icon: "move-horizontal",
11846
+ args: {
11847
+ axis: "horizontal",
11848
+ oscillating: false
11849
+ }
11850
+ },
11851
+ {
11852
+ id: "fan.verticalOscillateOn",
11853
+ method: "setOscillationAxis",
11854
+ label: t("Vertical oscillation on", "Oscillazione verticale attiva"),
11855
+ phrase: t("Start the vertical oscillation of {device}", "Attiva l’oscillazione verticale di {device}"),
11856
+ icon: "move-vertical",
11857
+ args: {
11858
+ axis: "vertical",
11859
+ oscillating: true
11860
+ }
11861
+ },
11862
+ {
11863
+ id: "fan.verticalOscillateOff",
11864
+ method: "setOscillationAxis",
11865
+ label: t("Vertical oscillation off", "Oscillazione verticale spenta"),
11866
+ phrase: t("Stop the vertical oscillation of {device}", "Ferma l’oscillazione verticale di {device}"),
11867
+ icon: "move-vertical",
11868
+ args: {
11869
+ axis: "vertical",
11870
+ oscillating: false
11871
+ }
11806
11872
  }
11807
11873
  ],
11808
11874
  state: [{
@@ -13892,10 +13958,6 @@ var CAMERA_CAP_VOCABULARY = {
13892
13958
  scope: "device",
13893
13959
  noun: t("Admin link", "Link di amministrazione")
13894
13960
  },
13895
- "device-discovery": {
13896
- scope: "device",
13897
- noun: t("Discovery", "Rilevamento dispositivi")
13898
- },
13899
13961
  "device-extension": {
13900
13962
  scope: "device",
13901
13963
  noun: t("Device extension", "Estensione del dispositivo")
@@ -15067,6 +15129,7 @@ var SYSTEM_CAP_VOCABULARY = {
15067
15129
  backup: system("system backups — run and restored from the Backups page"),
15068
15130
  broker: system("message-broker connections — configured, not actuated"),
15069
15131
  "calendar-source": system(INTERNAL),
15132
+ "cluster-update": system(CLUSTER),
15070
15133
  calendars: system("calendars feed schedules and conditions, not device actions"),
15071
15134
  composer: system("device composition — an authoring tool, not an action"),
15072
15135
  "core-blocks": system(INTERNAL),
@@ -15334,6 +15397,7 @@ var CAP_NAMES = Object.freeze([
15334
15397
  "camera-streams",
15335
15398
  "carbon-monoxide",
15336
15399
  "climate-control",
15400
+ "cluster-update",
15337
15401
  "color",
15338
15402
  "composer",
15339
15403
  "connectivity",
@@ -15351,7 +15415,6 @@ var CAP_NAMES = Object.freeze([
15351
15415
  "device-admin-link",
15352
15416
  "device-adoption",
15353
15417
  "device-credentials",
15354
- "device-discovery",
15355
15418
  "device-export",
15356
15419
  "device-extension",
15357
15420
  "device-history",
@@ -15973,13 +16036,6 @@ Object.freeze({
15973
16036
  "valueType": "string"
15974
16037
  }
15975
16038
  ],
15976
- "device-discovery": [{
15977
- "path": "lastDiscoveryAt",
15978
- "valueType": "number"
15979
- }, {
15980
- "path": "lastError",
15981
- "valueType": "string"
15982
- }],
15983
16039
  "device-export": [
15984
16040
  {
15985
16041
  "path": "linkState",
@@ -16168,6 +16224,10 @@ Object.freeze({
16168
16224
  "path": "lastEvent.eventType",
16169
16225
  "valueType": "string"
16170
16226
  },
16227
+ {
16228
+ "path": "lastEvent.sourceId",
16229
+ "valueType": "string"
16230
+ },
16171
16231
  {
16172
16232
  "path": "lastEvent.timestamp",
16173
16233
  "valueType": "number"
@@ -17431,7 +17491,7 @@ Object.freeze({
17431
17491
  }
17432
17492
  });
17433
17493
  //#endregion
17434
- //#region ../types/dist/implemented-methods-OitwyO1Y.mjs
17494
+ //#region ../types/dist/implemented-methods-5YMDeE3f.mjs
17435
17495
  /**
17436
17496
  * The ffmpeg build every CamStack node downloads, per platform and arch.
17437
17497
  *
@@ -44092,6 +44152,41 @@ function macroOnly(macro) {
44092
44152
  };
44093
44153
  }
44094
44154
  }
44155
+ var EventVocabularyKindSchema = _enum([
44156
+ "press",
44157
+ "long-press",
44158
+ "long-press-start",
44159
+ "release",
44160
+ "repeat",
44161
+ "double-press",
44162
+ "other"
44163
+ ]);
44164
+ /** The part of the device an event comes from: a button, a channel. */
44165
+ var EventVocabularySourceSchema = object({
44166
+ id: string().min(1),
44167
+ label: string().min(1)
44168
+ });
44169
+ /** One scalar field of an event's `data`, by dotted path. */
44170
+ var EventVocabularyFieldSchema = object({
44171
+ path: string().min(1),
44172
+ kind: _enum([
44173
+ "string",
44174
+ "number",
44175
+ "boolean",
44176
+ "enum"
44177
+ ]),
44178
+ enumValues: array(string()).optional(),
44179
+ label: string().min(1).optional()
44180
+ });
44181
+ var EventVocabularyEntrySchema = object({
44182
+ /** The native token, verbatim. */
44183
+ id: string().min(1),
44184
+ source: EventVocabularySourceSchema.optional(),
44185
+ label: string().min(1),
44186
+ description: string().optional(),
44187
+ kind: EventVocabularyKindSchema.optional(),
44188
+ payload: array(EventVocabularyFieldSchema).optional()
44189
+ });
44095
44190
  Math.PI / 180;
44096
44191
  /**
44097
44192
  * WHEN something happens, as data (D983) — the one time vocabulary of the
@@ -44144,6 +44239,62 @@ var WeekdayKeySchema = _enum([
44144
44239
  "sat",
44145
44240
  "sun"
44146
44241
  ]);
44242
+ /**
44243
+ * Hold semantics — "for N seconds" on a leaf of the `when` tree (#498).
44244
+ *
44245
+ * ONE primitive, `for: { ms, mode, tolerance? }`, shared by `device-becomes`,
44246
+ * `device-is` and `expression`:
44247
+ *
44248
+ * - `held`: the condition stays true for `ms`;
44249
+ * - `stable`: the value does not move by more than `tolerance` (absolute, in
44250
+ * the field's base unit) for `ms` while the condition holds.
44251
+ *
44252
+ * {@link HoldCore} is the pure state machine (an input observation → a reading).
44253
+ * {@link HoldTracker} keys cores by a leaf path for the STATE leaves the tree
44254
+ * evaluator judges. {@link BecomesWatch} wraps a core with a timer for the
44255
+ * `device-becomes` EVENT leaf: the deadline is armed when the hold starts and
44256
+ * cancelled when it breaks, and the leaf fires ONCE per episode.
44257
+ *
44258
+ * Hold state is memory only. A hold that is already running when the watcher
44259
+ * first looks (a restart) is INHERITED: nobody knows when it began, so it never
44260
+ * fires for that episode and a state leaf reads it as unknown until the window
44261
+ * has elapsed from the moment the watcher started (D49: not-known is not false).
44262
+ */
44263
+ var HOLD_MAX_MS = 864e5;
44264
+ var HoldModeSchema = _enum(["held", "stable"]);
44265
+ var HoldSpecSchema = object({
44266
+ /** How long the condition (or the value) must hold. */
44267
+ ms: number$1().int().min(1).max(HOLD_MAX_MS),
44268
+ mode: HoldModeSchema,
44269
+ /** `stable` only: the largest move of the value that still counts as unchanged (absolute, base unit). */
44270
+ tolerance: number$1().min(0).optional()
44271
+ });
44272
+ /** What a trace says about a hold. */
44273
+ var HoldTraceSchema = object({
44274
+ phase: _enum([
44275
+ "pending",
44276
+ "cancelled",
44277
+ "fired",
44278
+ "reseeded"
44279
+ ]),
44280
+ ms: number$1().int(),
44281
+ mode: HoldModeSchema,
44282
+ /** When the hold started (ms epoch). */
44283
+ since: number$1().optional(),
44284
+ /** When it completes (ms epoch), for a pending hold. */
44285
+ deadline: number$1().optional()
44286
+ });
44287
+ /** The operators a `device-becomes` leaf may carry: a threshold or a range. */
44288
+ var BecomesOperatorSchema = _enum([
44289
+ "eq",
44290
+ "ne",
44291
+ "gt",
44292
+ "gte",
44293
+ "lt",
44294
+ "lte",
44295
+ "between",
44296
+ "outside"
44297
+ ]);
44147
44298
  /** How a state leaf compares a device field to a value. Derived from the
44148
44299
  * field's `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
44149
44300
  var AutomationConditionOperatorSchema = _enum([
@@ -44157,6 +44308,13 @@ var AutomationConditionOperatorSchema = _enum([
44157
44308
  "in"
44158
44309
  ]);
44159
44310
  /**
44311
+ * The stable name of an event leaf. A path (`when.0.1`) moves when a row is
44312
+ * reordered; the id does not, so a `triggered-by` leaf keeps pointing at the
44313
+ * same row. Optional: a leaf nothing refers to needs none.
44314
+ */
44315
+ var EVENT_LEAF_ID_RE = /^[A-Za-z0-9_-]{1,40}$/;
44316
+ var eventLeafId = { id: string().regex(EVENT_LEAF_ID_RE).optional() };
44317
+ /**
44160
44318
  * D8 compliance, and it is the reason a "becomes" leaf is not merely an event
44161
44319
  * subscription: it evaluates against the **state mirror**, which is reconciled,
44162
44320
  * and an event only WAKES the evaluation. A dropped event therefore DELAYS a
@@ -44164,24 +44322,60 @@ var AutomationConditionOperatorSchema = _enum([
44164
44322
  */
44165
44323
  var DeviceBecomesLeafSchema = object({
44166
44324
  kind: literal("device-becomes"),
44325
+ ...eventLeafId,
44167
44326
  deviceId: number$1().int().nonnegative(),
44168
44327
  cap: string().min(1),
44169
44328
  fieldPath: string().min(1),
44170
- /** Fire when the field takes this value. Omit to fire on any change. */
44329
+ /** Fire when the field takes this value. Omit (with no `operator`) to fire on any change. */
44171
44330
  becomes: union([
44172
44331
  string(),
44173
44332
  number$1(),
44174
44333
  boolean()
44175
44334
  ]).optional(),
44176
- /** Only on a CHANGE of value, not on every re-report. */
44335
+ /**
44336
+ * A threshold or a range instead of an equality: "crosses above 3000", "enters
44337
+ * 18..24". The leaf fires on the TRANSITION into the predicate, never on a re-report.
44338
+ * `value` is in the field's base unit; `between` / `outside` take `[low, high]`.
44339
+ */
44340
+ operator: BecomesOperatorSchema.optional(),
44341
+ value: union([
44342
+ number$1(),
44343
+ string(),
44344
+ boolean(),
44345
+ array(number$1()).length(2)
44346
+ ]).optional(),
44347
+ /**
44348
+ * Hold: the transition (or, with no predicate, the new value) must stay for
44349
+ * `for.ms` before the leaf fires, ONCE per episode. See `hold.ts`.
44350
+ */
44351
+ for: HoldSpecSchema.optional(),
44352
+ /** @deprecated every leaf fires on a transition now; read as always on. */
44177
44353
  edge: boolean().optional(),
44178
- /** The new value must hold this long before it counts. */
44179
- forMs: number$1().int().min(0).max(864e5).optional(),
44354
+ /** @deprecated legacy `held` window — read through `holdOf` as `for: { ms, mode: 'held' }`. */
44355
+ forMs: number$1().int().min(0).max(HOLD_MAX_MS).optional(),
44180
44356
  /** Collapse a burst into one occurrence. */
44181
44357
  debounceMs: number$1().int().min(0).max(6e5).optional()
44358
+ }).superRefine((leaf, ctx) => {
44359
+ if (leaf.becomes !== void 0 && leaf.operator !== void 0) ctx.addIssue({
44360
+ code: "custom",
44361
+ message: "a leaf names `becomes` or `operator`, not both",
44362
+ path: ["operator"]
44363
+ });
44364
+ if (leaf.operator !== void 0 && leaf.value === void 0) ctx.addIssue({
44365
+ code: "custom",
44366
+ message: "an operator needs the value to compare to",
44367
+ path: ["value"]
44368
+ });
44369
+ const range = leaf.operator === "between" || leaf.operator === "outside";
44370
+ if (leaf.operator !== void 0 && range !== Array.isArray(leaf.value)) ctx.addIssue({
44371
+ code: "custom",
44372
+ message: range ? "a range takes [low, high]" : "only a range takes [low, high]",
44373
+ path: ["value"]
44374
+ });
44182
44375
  });
44183
44376
  var DeviceEventLeafSchema = object({
44184
44377
  kind: literal("device-event"),
44378
+ ...eventLeafId,
44185
44379
  /** An `EventCategory` value. */
44186
44380
  category: string().min(1),
44187
44381
  deviceId: number$1().int().nonnegative().optional()
@@ -44194,18 +44388,51 @@ var DeviceEventLeafSchema = object({
44194
44388
  */
44195
44389
  var ScheduleLeafSchema = object({
44196
44390
  kind: literal("schedule"),
44391
+ ...eventLeafId,
44197
44392
  at: TimeSpecSchema.optional(),
44198
44393
  days: array(WeekdayKeySchema).min(1).max(7).optional(),
44199
44394
  /** @deprecated legacy cron — new schedules use `at`. */
44200
44395
  cron: string().min(1).max(120).optional()
44201
44396
  }).refine((leaf) => leaf.at === void 0 !== (leaf.cron === void 0), { message: "a schedule names exactly one of `at` (a time spec) and `cron` (legacy)" }).refine((leaf) => leaf.days === void 0 || leaf.at !== void 0, { message: "`days` goes with `at`; a cron expression carries its own days" });
44202
- var ManualLeafSchema = object({ kind: literal("manual") });
44397
+ /**
44398
+ * One payload predicate of a `device-fired` row: the value at `path` inside
44399
+ * the fire's `data` (dotted) equals `equals`. Compared strictly, no coercion.
44400
+ */
44401
+ var DeviceFiredMatchSchema = object({
44402
+ path: string().min(1).max(120),
44403
+ equals: union([
44404
+ string(),
44405
+ number$1(),
44406
+ boolean()
44407
+ ])
44408
+ });
44409
+ /**
44410
+ * "fires": one device of the `event-emitter` capability reported an event
44411
+ * (D1045). Every list is an OR, the lists together an AND; an absent list does
44412
+ * not narrow. `eventIds` are the device's own tokens, `sourceIds` the parts of
44413
+ * the device (a button), `kinds` the shared press kinds of its vocabulary.
44414
+ */
44415
+ var DeviceFiredLeafSchema = object({
44416
+ kind: literal("device-fired"),
44417
+ ...eventLeafId,
44418
+ deviceId: number$1().int().nonnegative(),
44419
+ eventIds: array(string().min(1)).min(1).max(64).optional(),
44420
+ sourceIds: array(string().min(1)).min(1).max(64).optional(),
44421
+ kinds: array(EventVocabularyKindSchema).min(1).optional(),
44422
+ match: array(DeviceFiredMatchSchema).min(1).max(8).optional()
44423
+ });
44424
+ var ManualLeafSchema = object({
44425
+ kind: literal("manual"),
44426
+ ...eventLeafId
44427
+ });
44203
44428
  var IntervalLeafSchema = object({
44204
44429
  kind: literal("interval"),
44430
+ ...eventLeafId,
44205
44431
  minutes: number$1().int().min(1).max(1440)
44206
44432
  });
44207
44433
  var DetectionLeafSchema = object({
44208
44434
  kind: literal("detection"),
44435
+ ...eventLeafId,
44209
44436
  /** One macro class each, optionally narrowed by a label; a track of any listed subject fires. */
44210
44437
  subjects: array(DetectionSubjectSchema).min(1),
44211
44438
  deviceIds: array(number$1().int().nonnegative()).min(1),
@@ -44216,6 +44443,7 @@ var DetectionLeafSchema = object({
44216
44443
  });
44217
44444
  var StationaryLeafSchema = object({
44218
44445
  kind: literal("stationary"),
44446
+ ...eventLeafId,
44219
44447
  deviceIds: array(number$1().int().nonnegative()).min(1),
44220
44448
  subjects: array(StationarySubjectSchema).min(1).optional(),
44221
44449
  zones: array(string().min(1)).min(1).optional(),
@@ -44243,7 +44471,9 @@ var DeviceIsLeafSchema = object({
44243
44471
  QuantityValueSchema
44244
44472
  ]),
44245
44473
  /** The unit the person picked for `value` (display only; `value` is in the base unit). */
44246
- unit: string().min(1).optional()
44474
+ unit: string().min(1).optional(),
44475
+ /** The comparison must hold (`held`) or the value stay put (`stable`) this long. */
44476
+ for: HoldSpecSchema.optional()
44247
44477
  });
44248
44478
  /**
44249
44479
  * True when the camera currently has a PARKED object matching `subjects` in at
@@ -44259,6 +44489,41 @@ var StationaryPresentLeafSchema = object({
44259
44489
  minCount: number$1().int().positive().optional()
44260
44490
  });
44261
44491
  /**
44492
+ * True while the site-local clock is inside a daily window and/or on one of
44493
+ * the named days. `days` names the local day a window STARTS on, so
44494
+ * 22:00-06:00 on weekdays is still inside at 01:00 Saturday. A one-date spec
44495
+ * (`kind: 'date'`) is refused: only a clock time or a sun event makes a window
44496
+ * that repeats every day.
44497
+ */
44498
+ var TimeIsLeafSchema = object({
44499
+ kind: literal("time-is"),
44500
+ between: object({
44501
+ from: TimeSpecSchema,
44502
+ to: TimeSpecSchema
44503
+ }).optional(),
44504
+ days: array(WeekdayKeySchema).min(1).max(7).optional()
44505
+ }).superRefine((leaf, ctx) => {
44506
+ if (leaf.between === void 0 && leaf.days === void 0) ctx.addIssue({
44507
+ code: "custom",
44508
+ message: "a time-is leaf needs a between window, days, or both"
44509
+ });
44510
+ for (const end of ["from", "to"]) if (leaf.between?.[end].kind === "date") ctx.addIssue({
44511
+ code: "custom",
44512
+ message: "a one-date time cannot bound a daily window: use a clock time or a sun event",
44513
+ path: ["between", end]
44514
+ });
44515
+ });
44516
+ /**
44517
+ * True for the run that one of the named event leaves started. It reads the
44518
+ * run, not a device: in the tree it is judged against the leaf that fired, in
44519
+ * a step guard against the trigger the run carries. A run no event leaf
44520
+ * started (a state-only tree becoming true) is false.
44521
+ */
44522
+ var TriggeredByLeafSchema = object({
44523
+ kind: literal("triggered-by"),
44524
+ leafIds: array(string().regex(EVENT_LEAF_ID_RE)).min(1).max(20)
44525
+ });
44526
+ /**
44262
44527
  * The escape hatch: the bounded expression engine over named device bindings.
44263
44528
  * Declared as a plain object so a discriminated union can read `kind`; the
44264
44529
  * author-time validation is the SAME function `ExpressionSourceSchema` runs.
@@ -44266,7 +44531,9 @@ var StationaryPresentLeafSchema = object({
44266
44531
  var ExpressionLeafSchema = object({
44267
44532
  kind: literal("expression"),
44268
44533
  expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
44269
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
44534
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema),
44535
+ /** The expression must stay true this long. An expression has no single value, so only `held`. */
44536
+ for: HoldSpecSchema.optional()
44270
44537
  }).superRefine((src, ctx) => {
44271
44538
  const err = validateExpressionSource(src);
44272
44539
  if (err !== null) ctx.addIssue({
@@ -44274,6 +44541,11 @@ var ExpressionLeafSchema = object({
44274
44541
  message: err,
44275
44542
  path: ["expr"]
44276
44543
  });
44544
+ if (src.for?.mode === "stable") ctx.addIssue({
44545
+ code: "custom",
44546
+ message: "an expression is held, not stable: it has no single value to keep still",
44547
+ path: ["for", "mode"]
44548
+ });
44277
44549
  });
44278
44550
  var AutomationWhenSchema = lazy(() => discriminatedUnion("kind", [
44279
44551
  object({
@@ -44290,6 +44562,7 @@ var AutomationWhenSchema = lazy(() => discriminatedUnion("kind", [
44290
44562
  }),
44291
44563
  DeviceBecomesLeafSchema,
44292
44564
  DeviceEventLeafSchema,
44565
+ DeviceFiredLeafSchema,
44293
44566
  ScheduleLeafSchema,
44294
44567
  ManualLeafSchema,
44295
44568
  IntervalLeafSchema,
@@ -44297,11 +44570,14 @@ var AutomationWhenSchema = lazy(() => discriminatedUnion("kind", [
44297
44570
  StationaryLeafSchema,
44298
44571
  DeviceIsLeafSchema,
44299
44572
  StationaryPresentLeafSchema,
44300
- ExpressionLeafSchema
44573
+ ExpressionLeafSchema,
44574
+ TimeIsLeafSchema,
44575
+ TriggeredByLeafSchema
44301
44576
  ]));
44302
44577
  var EVENT_KINDS = new Set([
44303
44578
  "device-becomes",
44304
44579
  "device-event",
44580
+ "device-fired",
44305
44581
  "schedule",
44306
44582
  "manual",
44307
44583
  "interval",
@@ -44351,6 +44627,28 @@ function hasEventLeaves(node) {
44351
44627
  });
44352
44628
  return found;
44353
44629
  }
44630
+ /** The event leaves of the tree that carry an id, in tree order. */
44631
+ function identifiedEventLeaves(when) {
44632
+ const found = [];
44633
+ forEachLeaf(when, (leaf, path) => {
44634
+ if (isEventLeaf(leaf) && leaf.id !== void 0) found.push({
44635
+ id: leaf.id,
44636
+ path,
44637
+ leaf
44638
+ });
44639
+ });
44640
+ return found;
44641
+ }
44642
+ /** Does the tree hold a `triggered-by` leaf? */
44643
+ function hasTriggeredByLeaves(when) {
44644
+ let found = false;
44645
+ forEachLeaf(when, (leaf) => {
44646
+ if (leaf.kind === "triggered-by") found = true;
44647
+ });
44648
+ return found;
44649
+ }
44650
+ /** Why a `triggered-by` leaf is refused outside an automation: there is no trigger row to name. */
44651
+ var TRIGGERED_BY_NEEDS_AUTOMATION = "\"triggered by\" reads which trigger started an automation — an action or a scheduler has no trigger rows, so it could never be true here";
44354
44652
  function hasEmptyContainer(node) {
44355
44653
  if (isLeaf(node)) return false;
44356
44654
  if (node.kind === "not") return hasEmptyContainer(node.child);
@@ -44360,6 +44658,7 @@ function hasEmptyContainer(node) {
44360
44658
  var LEAF_WORDS = {
44361
44659
  "device-becomes": "a device value that becomes",
44362
44660
  "device-event": "a device event",
44661
+ "device-fired": "a device firing an event",
44363
44662
  schedule: "a schedule",
44364
44663
  manual: "a manual run",
44365
44664
  interval: "an interval",
@@ -44409,6 +44708,11 @@ function validateWhenTree(when) {
44409
44708
  if (depth > 4) return `the rules nest ${depth} levels deep — the limit is 4. "not (any of …)" is the same as "all of (not …)", which is one level shallower`;
44410
44709
  const leaves = countWhenLeaves(when);
44411
44710
  if (leaves > 32) return `the rules use ${leaves} rows — the limit is 32`;
44711
+ const seen = /* @__PURE__ */ new Set();
44712
+ for (const entry of identifiedEventLeaves(when)) {
44713
+ if (seen.has(entry.id)) return `two trigger rows share the id "${entry.id}" — an id names one row`;
44714
+ seen.add(entry.id);
44715
+ }
44412
44716
  const need = eventNeed(when, "when", false);
44413
44717
  if ("error" in need) return need.error;
44414
44718
  const [first, second] = need.paths;
@@ -44620,9 +44924,12 @@ function convertLegacyWhen(half) {
44620
44924
  * every caller — automations, Actions, and the notification centre, which runs
44621
44925
  * only Actions (planning#473).
44622
44926
  */
44927
+ /** The longest one `wait` step lasts. It is cut by a restart of the run and does not survive a hub restart. */
44928
+ var WAIT_MAX_SECONDS = 3600;
44929
+ var WAIT_TOO_LONG = `a wait is at most one hour (${WAIT_MAX_SECONDS} s)`;
44623
44930
  var AutomationWaitStepSchema = object({
44624
44931
  kind: literal("wait"),
44625
- seconds: number$1().min(0).max(300)
44932
+ seconds: number$1().min(0).max(WAIT_MAX_SECONDS, WAIT_TOO_LONG)
44626
44933
  });
44627
44934
  /** The legacy `deviceId` of a cap step: still READ, never written (D983). */
44628
44935
  var LegacyStepDeviceSchema = union([
@@ -44692,28 +44999,227 @@ var elseNeedsIf = {
44692
44999
  path: ["else"]
44693
45000
  };
44694
45001
  var GuardedWaitStepSchema = AutomationWaitStepSchema.extend(stepGuardShape).refine((step) => step.else === void 0 || step.if !== void 0, elseNeedsIf);
44695
- var GuardedCapStepSchema = AutomationCapStepBaseSchema.extend(stepGuardShape).refine((step) => step.target !== void 0 || step.deviceId !== void 0, {
45002
+ /**
45003
+ * A step of a STANDALONE list (an action, a scheduler point, a preset): the
45004
+ * same two step kinds an automation runs, minus a guard that reads the
45005
+ * automation's trigger, which such a list does not have.
45006
+ */
45007
+ var ActionStepSchema = discriminatedUnion("kind", [GuardedWaitStepSchema, AutomationCapStepBaseSchema.extend(stepGuardShape).refine((step) => step.target !== void 0 || step.deviceId !== void 0, {
45008
+ message: "a cap step needs a target",
45009
+ path: ["target"]
45010
+ }).refine((step) => step.target?.kind !== "domain" || step.actionId !== void 0, {
45011
+ message: "a domain target needs the vocabulary action it applies to (actionId)",
45012
+ path: ["actionId"]
45013
+ }).refine((step) => step.else === void 0 || step.if !== void 0, elseNeedsIf)]).superRefine((step, ctx) => {
45014
+ if (step.if !== void 0 && hasTriggeredByLeaves(step.if)) ctx.addIssue({
45015
+ code: "custom",
45016
+ message: TRIGGERED_BY_NEEDS_AUTOMATION,
45017
+ path: ["if"]
45018
+ });
45019
+ });
45020
+ /**
45021
+ * Flow control of an AUTOMATION's list (#500). A standalone list (an action, a
45022
+ * scheduler point, a preset) stays `wait` and `cap`: it has no run of its own
45023
+ * to end and no variable bag to write.
45024
+ */
45025
+ var continueOnErrorShape = {
45026
+ /** A failure of this step is traced `failed-continued` and the next step runs. */
45027
+ continueOnError: literal(true).optional() };
45028
+ /** An automation's cap step: the shared guarded cap step, plus `continueOnError`. */
45029
+ var AutomationGuardedCapStepSchema = AutomationCapStepBaseSchema.extend({
45030
+ ...stepGuardShape,
45031
+ ...continueOnErrorShape
45032
+ }).refine((step) => step.target !== void 0 || step.deviceId !== void 0, {
44696
45033
  message: "a cap step needs a target",
44697
45034
  path: ["target"]
44698
45035
  }).refine((step) => step.target?.kind !== "domain" || step.actionId !== void 0, {
44699
45036
  message: "a domain target needs the vocabulary action it applies to (actionId)",
44700
45037
  path: ["actionId"]
44701
45038
  }).refine((step) => step.else === void 0 || step.if !== void 0, elseNeedsIf);
44702
- var ActionStepSchema = discriminatedUnion("kind", [GuardedWaitStepSchema, GuardedCapStepSchema]);
45039
+ /** End the run here, on purpose. `reason` may carry `{{vars.x}}` slots. */
45040
+ var AutomationStopStepSchema = object({
45041
+ kind: literal("stop"),
45042
+ reason: string().min(1, "a stop needs a reason: it is what the run trace shows").max(200),
45043
+ /** `ok`: the run ends as a success. `failed`: it ends as a failure, with the reason. */
45044
+ outcome: _enum(["ok", "failed"]),
45045
+ /** Stop only when this reads true; false or unknown does not stop. */
45046
+ if: AutomationWhenSchema.optional()
45047
+ });
45048
+ var SET_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,39}$/;
45049
+ /** Names the run itself writes and reads (`leaf` is what `triggered-by` judges): a step may not overwrite them. */
45050
+ var RESERVED_RUN_VARS = [
45051
+ "kind",
45052
+ "leaf",
45053
+ "at"
45054
+ ];
45055
+ /**
45056
+ * Write variables into the run's bag, for the steps after it. SUBSTITUTION
45057
+ * only, never evaluation: a text value may carry `{{vars.x}}` slots.
45058
+ */
45059
+ var AutomationSetStepSchema = object({
45060
+ kind: literal("set"),
45061
+ vars: record(string(), union([
45062
+ string().max(500),
45063
+ number$1(),
45064
+ boolean()
45065
+ ])).refine((vars) => Object.keys(vars).every((name) => SET_VAR_NAME_RE.test(name)), { message: "a variable name is letters, digits and _, not starting with a digit (max 40)" }).refine((vars) => Object.keys(vars).length >= 1, { message: "set needs at least one variable" }).refine((vars) => Object.keys(vars).length <= 20, { message: `set holds at most 20 variables` }).refine((vars) => Object.keys(vars).every((name) => !RESERVED_RUN_VARS.includes(name)), { message: `the names ${RESERVED_RUN_VARS.join(", ")} belong to the run and cannot be set` }),
45066
+ /** Set only when this reads true; false or unknown leaves the bag as it is. */
45067
+ if: AutomationWhenSchema.optional()
45068
+ });
45069
+ var AutomationRunActionStepSchema = object({
45070
+ kind: literal("action"),
45071
+ /** An entry of the `actions` surface (`internal:<uuid>` or `<addonId>:<localId>`). */
45072
+ actionId: string().min(1),
45073
+ /** Values for the action's declared variables; the others take their default. */
45074
+ variables: record(string(), union([
45075
+ string(),
45076
+ number$1(),
45077
+ boolean()
45078
+ ])).optional(),
45079
+ ...continueOnErrorShape
45080
+ });
45081
+ /**
45082
+ * A step INSIDE a branch of a `choose`: a flow step with no condition of its
45083
+ * own (the branch is the condition) and no block inside it. Strict, so an `if`
45084
+ * written on such a step is refused rather than dropped.
45085
+ */
45086
+ /** The longest a `wait-until` may wait: six hours. */
45087
+ var WAIT_UNTIL_TIMEOUT_MAX_S = 21600;
45088
+ /**
45089
+ * Pause the run until a condition is true (#500). The timeout is mandatory: a
45090
+ * run never waits without an end. `onTimeout` says what a wait that ran out
45091
+ * does: `continue` goes on to the next step, `stop` ends the run as a failure.
45092
+ * A condition that cannot be read is not "true": the wait goes on (D49).
45093
+ */
45094
+ var AutomationWaitUntilStepSchema = object({
45095
+ kind: literal("wait-until"),
45096
+ when: AutomationWhenSchema,
45097
+ timeoutS: number$1().int().min(1, "a wait needs a timeout of at least one second").max(WAIT_UNTIL_TIMEOUT_MAX_S, `a wait for a condition is at most six hours (${WAIT_UNTIL_TIMEOUT_MAX_S} s)`),
45098
+ onTimeout: _enum(["continue", "stop"])
45099
+ }).strict();
45100
+ /** The step kinds that hold steps of their own, or pause on a condition: they nest two levels at most. */
45101
+ var BLOCK_STEP_KINDS = [
45102
+ "choose",
45103
+ "repeat",
45104
+ "parallel",
45105
+ "wait-until"
45106
+ ];
45107
+ var NEST_TOO_DEEP = `steps nest two levels at most: a step inside a nested choose or repeat can be a plain step only`;
45108
+ function kindOf(input) {
45109
+ return typeof input === "object" && input !== null ? Reflect.get(input, "kind") : void 0;
45110
+ }
45111
+ /** Where the nesting ends, a block step is refused in words rather than as an unknown kind. */
45112
+ function nestTooDeep(issue) {
45113
+ const kind = kindOf(issue.input);
45114
+ return typeof kind === "string" && BLOCK_STEP_KINDS.includes(kind) ? NEST_TOO_DEEP : void 0;
45115
+ }
45116
+ var plainStepSchemas = [
45117
+ AutomationWaitStepSchema.strict(),
45118
+ AutomationCapStepBaseSchema.extend(continueOnErrorShape).strict().refine((step) => step.target !== void 0 || step.deviceId !== void 0, {
45119
+ message: "a cap step needs a target",
45120
+ path: ["target"]
45121
+ }).refine((step) => step.target?.kind !== "domain" || step.actionId !== void 0, {
45122
+ message: "a domain target needs the vocabulary action it applies to (actionId)",
45123
+ path: ["actionId"]
45124
+ }),
45125
+ AutomationStopStepSchema.omit({ if: true }).strict(),
45126
+ AutomationSetStepSchema.omit({ if: true }).strict(),
45127
+ AutomationRunActionStepSchema.strict()
45128
+ ];
45129
+ /**
45130
+ * A plain step: a flow step with no condition of its own and no step inside
45131
+ * it. The second (last) level of nesting holds these only.
45132
+ */
45133
+ var AutomationBranchStepSchema = discriminatedUnion("kind", plainStepSchemas, { error: nestTooDeep });
45134
+ /** A step that can run beside others: it writes no variable and does not end the run. */
45135
+ var AutomationParallelMemberSchema = AutomationBranchStepSchema.refine((step) => step.kind === "cap" || step.kind === "action" || step.kind === "wait", "a parallel holds device commands, actions and waits only: a set or a stop beside other steps has no order");
45136
+ /**
45137
+ * Several steps at once (#500): all are started together and all are awaited.
45138
+ * A failure fails the run once the others finished, as in a sequence; a member
45139
+ * marked "continue if this fails" does not.
45140
+ */
45141
+ var AutomationParallelStepSchema = object({
45142
+ kind: literal("parallel"),
45143
+ steps: array(AutomationParallelMemberSchema).min(2, "a parallel needs at least two steps").max(5, `a parallel takes at most 5 steps`)
45144
+ }).strict();
45145
+ var REPEAT_NEEDS_MAX = "a repeat on a condition needs a maximum number of iterations";
45146
+ /** A loop on a condition: the bound is mandatory, so a run never loops without an end. */
45147
+ var repeatConditionShape = {
45148
+ when: AutomationWhenSchema,
45149
+ maxIterations: number$1({ error: REPEAT_NEEDS_MAX }).int().min(1, REPEAT_NEEDS_MAX).max(50, `a repeat on a condition runs at most 50 times`),
45150
+ /** A pause between two iterations, never after the last. */
45151
+ waitS: number$1().int().min(1).max(WAIT_MAX_SECONDS, WAIT_TOO_LONG).optional()
45152
+ };
45153
+ /** What decides how many times a `repeat` runs. */
45154
+ var AutomationRepeatLoopSchema = discriminatedUnion("by", [
45155
+ object({
45156
+ by: literal("count"),
45157
+ count: number$1().int().min(1).max(20, `a repeat runs at most 20 times`)
45158
+ }).strict(),
45159
+ object({
45160
+ by: literal("while"),
45161
+ ...repeatConditionShape
45162
+ }).strict(),
45163
+ object({
45164
+ by: literal("until"),
45165
+ ...repeatConditionShape
45166
+ }).strict(),
45167
+ object({
45168
+ by: literal("forEach"),
45169
+ target: object({
45170
+ kind: literal("devices"),
45171
+ ids: array(number$1().int()).min(1, "a repeat over devices needs at least one device").max(50, `a repeat over devices takes at most 50 devices`)
45172
+ }).strict()
45173
+ }).strict()
45174
+ ]);
45175
+ /**
45176
+ * The same steps several times (#500). Every form is bounded at save, so a
45177
+ * run cannot loop without an end. A failure or a `stop` in the steps ends the
45178
+ * loop and the run, as in a sequence.
45179
+ */
45180
+ function repeatSchemaOf(step) {
45181
+ return object({
45182
+ kind: literal("repeat"),
45183
+ loop: AutomationRepeatLoopSchema,
45184
+ steps: array(step).min(1, "a repeat needs at least one step").max(10, `a repeat takes at most 10 steps`)
45185
+ }).strict();
45186
+ }
45187
+ /**
45188
+ * One step, several ways (#500): the branches are read in order and the FIRST
45189
+ * whose condition is true runs; `else` runs when every branch is false. A
45190
+ * branch that cannot be read ends the choice: nothing runs, not the branches
45191
+ * after it and not the else (D49). The #472 `if` / `else` of a wait or cap
45192
+ * step is the one-branch case and stays as it is stored.
45193
+ */
45194
+ function chooseSchemaOf(step) {
45195
+ const steps = array(step).max(10);
45196
+ return object({
45197
+ kind: literal("choose"),
45198
+ branches: array(object({
45199
+ if: AutomationWhenSchema,
45200
+ steps: steps.min(1, "a branch needs at least one step")
45201
+ })).min(1, "a choose needs at least one branch").max(6, `a choose takes at most 6 branches`),
45202
+ else: steps.optional()
45203
+ });
45204
+ }
45205
+ var AutomationBodyStepSchema = discriminatedUnion("kind", [
45206
+ ...plainStepSchemas,
45207
+ AutomationWaitUntilStepSchema,
45208
+ AutomationParallelStepSchema,
45209
+ chooseSchemaOf(AutomationBranchStepSchema),
45210
+ repeatSchemaOf(AutomationBranchStepSchema)
45211
+ ]);
45212
+ var AutomationRepeatStepSchema = repeatSchemaOf(AutomationBodyStepSchema);
44703
45213
  var AutomationActionSchema = discriminatedUnion("kind", [
44704
45214
  GuardedWaitStepSchema,
44705
- GuardedCapStepSchema,
44706
- object({
44707
- kind: literal("action"),
44708
- /** An entry of the `actions` surface (`internal:<uuid>` or `<addonId>:<localId>`). */
44709
- actionId: string().min(1),
44710
- /** Values for the action's declared variables; the others take their default. */
44711
- variables: record(string(), union([
44712
- string(),
44713
- number$1(),
44714
- boolean()
44715
- ])).optional()
44716
- }),
45215
+ AutomationGuardedCapStepSchema,
45216
+ AutomationStopStepSchema,
45217
+ AutomationSetStepSchema,
45218
+ chooseSchemaOf(AutomationBodyStepSchema),
45219
+ AutomationWaitUntilStepSchema,
45220
+ AutomationParallelStepSchema,
45221
+ AutomationRepeatStepSchema,
45222
+ AutomationRunActionStepSchema,
44717
45223
  object({
44718
45224
  kind: literal("code"),
44719
45225
  /** Compiled into the automation's OWN block by esbuild — not a third
@@ -44776,11 +45282,6 @@ function convertLegacyRecipe(legacy) {
44776
45282
  ...rest
44777
45283
  };
44778
45284
  }
44779
- /**
44780
- * Where recipes are PARSED (the core-blocks cap, the MCP tool, the literal a
44781
- * saved block carries): the new shape as it is, or the old shape converted to
44782
- * it. The output type is always the new one.
44783
- */
44784
45285
  var AutomationRecipeSchema = union([CurrentAutomationRecipeSchema, LegacyAutomationRecipeSchema.transform(convertLegacyRecipe)]);
44785
45286
  var DETECTION_DESCRIPTIONS = {
44786
45287
  deviceId: "the camera that saw it",
@@ -44796,6 +45297,21 @@ var STATIONARY_DESCRIPTIONS = {
44796
45297
  phase: "appeared or departed",
44797
45298
  entryId: "the stopped object"
44798
45299
  };
45300
+ var BECOMES_DESCRIPTIONS = {
45301
+ deviceId: "the device that changed",
45302
+ from: "the value before the change, empty when it is not known",
45303
+ to: "the value it changed to"
45304
+ };
45305
+ /**
45306
+ * A fire's payload is offered too, as `data.<path>` (`{{vars.data.channel}}`):
45307
+ * those names depend on the device, so they are not in this list.
45308
+ */
45309
+ var FIRED_DESCRIPTIONS = {
45310
+ deviceId: "the device that fired",
45311
+ eventType: "the event, as the device names it",
45312
+ sourceId: "the part of the device that fired (a button), empty when it names none",
45313
+ eventKind: "the kind of press (press, long-press …), empty when the device declares none"
45314
+ };
44799
45315
  function describe$1(descriptions) {
44800
45316
  return Object.entries(descriptions).map(([name, description]) => ({
44801
45317
  name,
@@ -44804,8 +45320,74 @@ function describe$1(descriptions) {
44804
45320
  }
44805
45321
  describe$1(DETECTION_DESCRIPTIONS);
44806
45322
  describe$1(STATIONARY_DESCRIPTIONS);
45323
+ describe$1(FIRED_DESCRIPTIONS).map((v) => v.name === "deviceId" ? {
45324
+ ...v,
45325
+ hintKey: "firedDeviceId"
45326
+ } : v);
45327
+ describe$1(BECOMES_DESCRIPTIONS).map((v) => v.name === "deviceId" ? {
45328
+ ...v,
45329
+ hintKey: "becomesDeviceId"
45330
+ } : v);
44807
45331
  new Set(Object.values(COCO_TO_MACRO.mapping));
44808
45332
  /**
45333
+ * Generic stateless event emitter. Installed on a `DeviceType.EventEmitter`
45334
+ * device. Carries the device's EXACT declared event vocabulary verbatim
45335
+ * (NO normalization). Two shapes:
45336
+ * - structured source (HA `event.*` entity) → `vocabulary` is the fixed
45337
+ * declared set (e.g. press_short, press_long, …).
45338
+ * - generic/legacy source (HA bus events, e.g. zha_event) → `vocabulary`
45339
+ * is [] (open) and `lastEvent.data` carries the complex payload.
45340
+ * `vocabulary` (D1044) names each event for a person and types it for a
45341
+ * rule; `eventTypes` is its list of native tokens, kept for older readers.
45342
+ * A fire names the part of the device it came from in `sourceId` when the
45343
+ * vocabulary declares sources (a remote's buttons).
45344
+ * `seq` is monotonic so two identical events still produce distinct slice writes.
45345
+ */
45346
+ var EventFireSchema = object({
45347
+ deviceId: number$1(),
45348
+ eventType: string(),
45349
+ sourceId: string().optional(),
45350
+ data: record(string(), unknown()).nullable(),
45351
+ timestamp: number$1().meta(readingMeta("none")),
45352
+ seq: number$1().meta(readingMeta("count"))
45353
+ });
45354
+ var EventEmitterStatusSchema = object({
45355
+ eventTypes: array(string()),
45356
+ /**
45357
+ * Required of a provider by the type. A status written before the field
45358
+ * existed still parses, as an empty vocabulary: read it through
45359
+ * `readEventVocabulary`, which then answers with the plain tokens.
45360
+ */
45361
+ vocabulary: array(EventVocabularyEntrySchema).default([]),
45362
+ lastEvent: EventFireSchema.nullable(),
45363
+ eventCountSinceStart: number$1().meta(readingMeta("count"))
45364
+ });
45365
+ var eventEmitterCapability = {
45366
+ name: "event-emitter",
45367
+ scope: "device",
45368
+ deviceNative: true,
45369
+ mode: "singleton",
45370
+ deviceTypes: [DeviceType.EventEmitter],
45371
+ methods: {},
45372
+ events: { onEvent: { data: EventFireSchema } },
45373
+ status: {
45374
+ schema: EventEmitterStatusSchema,
45375
+ kind: "push"
45376
+ },
45377
+ runtimeState: EventEmitterStatusSchema,
45378
+ /**
45379
+ * Runtime-state durability: **session** — `eventCountSinceStart` names its own scope.
45380
+ *
45381
+ * See `RuntimeStateDurability`. Enforced by
45382
+ * `scripts/check-runtime-state-durability.ts`.
45383
+ */
45384
+ durability: "session",
45385
+ /**
45386
+ * History (D850): none — `eventCountSinceStart` resets with the addon; events live in ledgers.
45387
+ */
45388
+ history: "none"
45389
+ };
45390
+ /**
44809
45391
  * Schedulers (D983) — an entity that performs actions over the day.
44810
45392
  *
44811
45393
  * A scheduler has profiles; a profile has weekdays and a timeline of POINTS.
@@ -44828,6 +45410,13 @@ var SchedulerStateTreeSchema = AutomationWhenSchema.superRefine((when, ctx) => {
44828
45410
  });
44829
45411
  return;
44830
45412
  }
45413
+ if (hasTriggeredByLeaves(when)) {
45414
+ ctx.addIssue({
45415
+ code: "custom",
45416
+ message: TRIGGERED_BY_NEEDS_AUTOMATION
45417
+ });
45418
+ return;
45419
+ }
44831
45420
  const problem = validateWhenTree(when);
44832
45421
  if (problem !== null) ctx.addIssue({
44833
45422
  code: "custom",
@@ -44849,12 +45438,16 @@ var HoldOverrideSchema = discriminatedUnion("mode", [object({ mode: literal("res
44849
45438
  })]);
44850
45439
  /** A missed edge up to this late still runs. */
44851
45440
  var DEFAULT_MAX_LATE_MS = 10 * 6e4;
45441
+ var SchedulerStepSchema = ActionStepSchema.refine((step) => step.kind !== "wait" || step.seconds <= 300, {
45442
+ message: `a scheduler point waits at most five minutes (300 s); put a longer wait in an action`,
45443
+ path: ["seconds"]
45444
+ });
44852
45445
  var SchedulerPointSchema = object({
44853
45446
  id: string().min(1).max(64),
44854
45447
  at: SchedulerTimeSpecSchema,
44855
45448
  mode: SchedulerPointModeSchema,
44856
45449
  /** The actions of an `edge` or `hold`; a `release` has none. */
44857
- steps: array(ActionStepSchema).max(20).default([]),
45450
+ steps: array(SchedulerStepSchema).max(20).default([]),
44858
45451
  /** Checked when the point runs (an edge) or is applied (a hold). */
44859
45452
  conditions: SchedulerStateTreeSchema.optional(),
44860
45453
  /** Hold only; absent means `respect`. */
@@ -45033,7 +45626,9 @@ var AutomationTriggerTraceSchema = object({
45033
45626
  /** Why an event was rejected ("class person not in [dog]") or what fired. */
45034
45627
  reason: string(),
45035
45628
  /** deviceId, className, trackId, score … — only what the trigger exposes. */
45036
- vars: record(string(), TraceValueSchema)
45629
+ vars: record(string(), TraceValueSchema),
45630
+ /** A hold (`for`) this occurrence went through: pending, cancelled, fired or re-seeded. */
45631
+ hold: HoldTraceSchema.optional()
45037
45632
  });
45038
45633
  var AutomationConditionTraceNodeSchema = object({
45039
45634
  /** `when` for the root, `when.<i>` for its i-th child, … (`conditions…` on an old trace). */
@@ -45046,8 +45641,11 @@ var AutomationConditionTraceNodeSchema = object({
45046
45641
  "device-is",
45047
45642
  "expression",
45048
45643
  "stationary-present",
45644
+ "time-is",
45645
+ "triggered-by",
45049
45646
  "device-becomes",
45050
45647
  "device-event",
45648
+ "device-fired",
45051
45649
  "schedule",
45052
45650
  "interval",
45053
45651
  "manual",
@@ -45118,18 +45716,46 @@ var StepTraceBaseSchema = object({
45118
45716
  ]).optional(),
45119
45717
  /** Why the condition read what it read, in the evaluator's own words. */
45120
45718
  conditionReason: string().optional(),
45121
- /** `empty`: the target resolved to no device, so nothing was done — never silently `ok`. `skipped`: its `if` was false (or unknown) and nothing ran in its place. */
45719
+ /** `empty`: the target resolved to no device, so nothing was done — never silently `ok`. `skipped`: its `if` was false (or unknown) and nothing ran in its place. `failed-continued`: it failed and was marked to be passed over (#500). */
45122
45720
  outcome: _enum([
45123
45721
  "ok",
45124
45722
  "error",
45125
45723
  "not-run",
45126
45724
  "empty",
45127
- "skipped"
45725
+ "skipped",
45726
+ "failed-continued",
45727
+ "timed-out"
45128
45728
  ]),
45129
45729
  message: string().optional()
45130
45730
  });
45731
+ /** What a block step adds to its row: which list ran, what each branch read, how many rounds. */
45732
+ var blockTraceShape = {
45733
+ /** A `choose` step (#500): the list that ran, `branches.<n>` or `else`. Absent when none did. */
45734
+ branch: string().optional(),
45735
+ /** A `choose` step: what each branch read, in order, up to the one that decided. */
45736
+ branchChecks: array(object({
45737
+ condition: _enum([
45738
+ "true",
45739
+ "false",
45740
+ "unknown"
45741
+ ]),
45742
+ conditionReason: string().optional()
45743
+ })).optional(),
45744
+ /** A `repeat` step (#500): how many times its steps ran. */
45745
+ iterations: number$1().int().optional()
45746
+ };
45747
+ /** A step one level down: it may be a block step itself, whose own steps are plain rows (the nesting ends there). */
45748
+ var NestedStepTraceSchema = StepTraceBaseSchema.extend({
45749
+ ...blockTraceShape,
45750
+ steps: array(StepTraceBaseSchema).optional()
45751
+ });
45131
45752
  /** A step trace; the `else` steps that ran are one level of the same shape (the nesting is bounded at one). */
45132
- var AutomationStepTraceSchema = StepTraceBaseSchema.extend({ else: array(StepTraceBaseSchema).optional() });
45753
+ var AutomationStepTraceSchema = StepTraceBaseSchema.extend({
45754
+ else: array(StepTraceBaseSchema).optional(),
45755
+ ...blockTraceShape,
45756
+ /** A `choose` step: the steps of the list that ran. A `parallel`: its members. A `repeat`: the LAST iteration. */
45757
+ steps: array(NestedStepTraceSchema).optional()
45758
+ });
45133
45759
  var AutomationRunTraceSchema = object({
45134
45760
  runId: string().min(1),
45135
45761
  startedAt: number$1(),
@@ -45379,7 +46005,7 @@ object({
45379
46005
  * is irrelevant; the array is treated as a set by consumers. */
45380
46006
  hiddenChildIds: array(number$1()).readonly()
45381
46007
  });
45382
- DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Light, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Cover, DeviceType.Lock, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Generic, method(object({
46008
+ DeviceType.Camera, DeviceType.Switch, DeviceType.Light, DeviceType.Sensor, DeviceType.Climate, DeviceType.Cover, DeviceType.Lock, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Generic, method(object({
45383
46009
  deviceId: number$1().int().nonnegative(),
45384
46010
  childDeviceId: number$1().int().nonnegative(),
45385
46011
  hidden: boolean()
@@ -45536,7 +46162,9 @@ var AddonHttpRouteSchema = object({
45536
46162
  ]).optional(),
45537
46163
  description: string().optional(),
45538
46164
  /** The hub limits this route per client address before the handler runs. */
45539
- rateLimit: _enum(["auth"]).optional()
46165
+ rateLimit: _enum(["auth"]).optional(),
46166
+ /** Served once per integration instance; the handler reads `instanceId`. */
46167
+ perInstance: boolean().optional()
45540
46168
  });
45541
46169
  /**
45542
46170
  * Cross-process route invocation envelope. The hub captures the
@@ -45553,6 +46181,8 @@ var InvokeRequestSchema = object({
45553
46181
  query: record(string(), string()),
45554
46182
  body: unknown(),
45555
46183
  headers: record(string(), string()),
46184
+ /** The instance the hub resolved for a `perInstance` route. */
46185
+ instanceId: string().optional(),
45556
46186
  user: object({
45557
46187
  id: string(),
45558
46188
  username: string(),
@@ -47677,7 +48307,7 @@ var BATTERY_DEVICE_TYPES = [
47677
48307
  DeviceType.Switch,
47678
48308
  DeviceType.PetFeeder,
47679
48309
  DeviceType.Container,
47680
- DeviceType.Thermostat,
48310
+ DeviceType.Climate,
47681
48311
  DeviceType.EventEmitter
47682
48312
  ];
47683
48313
  /** Generic Customize target for the percentage reading (D936). */
@@ -49440,7 +50070,14 @@ var climateControlCapability = {
49440
50070
  scope: "device",
49441
50071
  deviceNative: true,
49442
50072
  mode: "singleton",
49443
- deviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
50073
+ deviceTypes: [DeviceType.Climate],
50074
+ power: {
50075
+ kind: "mode",
50076
+ field: "mode",
50077
+ offValue: "off",
50078
+ set: "setMode",
50079
+ lastField: "lastActiveMode"
50080
+ },
49444
50081
  methods: {
49445
50082
  setMode: method(object({
49446
50083
  deviceId: number$1().int().nonnegative(),
@@ -49550,6 +50187,140 @@ var climateControlCapability = {
49550
50187
  } }
49551
50188
  };
49552
50189
  /**
50190
+ * cluster-update — the ONE flow that brings the whole cluster to a release
50191
+ * (planning#493). Hub-provided (`server-provided`), hub-only: the hub runs
50192
+ * the sequence itself, so the admin UI, the MCP and the CLI all start the same
50193
+ * job and read the same report instead of each driving their own loop.
50194
+ *
50195
+ * 1. backup (`backup.trigger`) — skipped only when the caller says so
50196
+ * 2. the hub's server closure (`applyServerUpdate`); the hub restarts and the
50197
+ * job RESUMES on the new boot, verified against the hub's running version
50198
+ * 3. every online agent behind the target, one at a time, verified: runs the
50199
+ * target, settled, online, and its capabilities registered (D848) — an
50200
+ * agent that comes back capless is a FAILED step, never a done one
50201
+ * 4. every stale addon on the hub (`addons.updatePackage`); agents follow by
50202
+ * reconciliation, which is verified, never pushed
50203
+ *
50204
+ * The job stops on the first failed step with a typed reason (D877). Its state
50205
+ * is durable on the hub and read by RPC (`getClusterUpdateJob`) — events are
50206
+ * telemetry and never carry it (D8, D11).
50207
+ */
50208
+ /** The steps, in the order they run. */
50209
+ var ClusterUpdateStepKindSchema = _enum([
50210
+ "backup",
50211
+ "hub-server",
50212
+ "agent-server",
50213
+ "addons"
50214
+ ]);
50215
+ /**
50216
+ * - `pending` — not reached yet.
50217
+ * - `running` — in progress (for `hub-server`: possibly across the restart).
50218
+ * - `ok` — done AND verified.
50219
+ * - `failed` — did not land; `reason` + `detail` say why. Stops the job.
50220
+ * - `skipped` — not run: nothing to do, opted out, the node is offline, or a
50221
+ * step before it failed. `detail` says which.
50222
+ */
50223
+ var ClusterUpdateStepStatusSchema = _enum([
50224
+ "pending",
50225
+ "running",
50226
+ "ok",
50227
+ "failed",
50228
+ "skipped"
50229
+ ]);
50230
+ /**
50231
+ * Why a step (and with it the job) failed. Stable, greppable; `detail` carries
50232
+ * the prose.
50233
+ */
50234
+ var ClusterUpdateFailureReasonSchema = _enum([
50235
+ "backup-unavailable",
50236
+ "backup-failed",
50237
+ "server-refused",
50238
+ "server-not-on-target",
50239
+ "node-capless",
50240
+ "status-unreadable",
50241
+ "addon-update-failed",
50242
+ "addons-not-converged",
50243
+ "interrupted"
50244
+ ]);
50245
+ var ClusterUpdateStepSchema = object({
50246
+ kind: ClusterUpdateStepKindSchema,
50247
+ /** The node the step acts on; absent for `backup` and `addons`. */
50248
+ nodeId: string().optional(),
50249
+ status: ClusterUpdateStepStatusSchema,
50250
+ /** One line for the operator: what happened, or why not. */
50251
+ detail: string(),
50252
+ reason: ClusterUpdateFailureReasonSchema.optional(),
50253
+ /** `hub-server` / `agent-server`: the version the node ran before. */
50254
+ fromVersion: string().nullable().optional(),
50255
+ /** `addons`: the packages this step updates, `name from -> to`. */
50256
+ packages: array(string()).readonly().optional(),
50257
+ startedAt: number$1().optional(),
50258
+ finishedAt: number$1().optional()
50259
+ });
50260
+ var ClusterUpdateJobStateSchema = _enum([
50261
+ "running",
50262
+ "succeeded",
50263
+ "failed"
50264
+ ]);
50265
+ var ClusterUpdateJobSchema = object({
50266
+ id: string(),
50267
+ state: ClusterUpdateJobStateSchema,
50268
+ /** The server version every node is brought to. */
50269
+ targetVersion: string(),
50270
+ /** Who started it (the user name, or `system`). */
50271
+ requestedBy: string(),
50272
+ startedAt: number$1(),
50273
+ finishedAt: number$1().optional(),
50274
+ /** Every step, in run order; later steps stay `pending` while it runs. */
50275
+ steps: array(ClusterUpdateStepSchema).readonly(),
50276
+ /** On `failed`: the step that stopped it (`nodeId` or the step kind). */
50277
+ stoppedAt: string().optional(),
50278
+ reason: ClusterUpdateFailureReasonSchema.optional(),
50279
+ /** Agents offline when the plan was read: never touched. */
50280
+ skippedOffline: array(string()).readonly()
50281
+ });
50282
+ var ClusterUpdateOptionsSchema = object({
50283
+ /** Explicit server target; omitted = the hub's latest registry check. */
50284
+ version: string().optional(),
50285
+ /** Update the addons too (default true). */
50286
+ addons: boolean().optional(),
50287
+ /**
50288
+ * Back up before touching anything (default true). `false` is an explicit
50289
+ * operator opt-out; a missing backup provider is otherwise a failure.
50290
+ */
50291
+ backup: boolean().optional()
50292
+ });
50293
+ /** Why a plan cannot start. */
50294
+ var ClusterUpdateBlockerSchema = _enum([
50295
+ "no-target",
50296
+ "node-busy",
50297
+ "job-running",
50298
+ "nothing-to-do",
50299
+ "status-unreadable"
50300
+ ]);
50301
+ var ClusterUpdatePlanSchema = object({
50302
+ /** `null` when no target could be resolved (`blocker: 'no-target'`). */
50303
+ targetVersion: string().nullable(),
50304
+ /** The steps the job WOULD run, all `pending` (or `skipped` with a reason). */
50305
+ steps: array(ClusterUpdateStepSchema).readonly(),
50306
+ skippedOffline: array(string()).readonly(),
50307
+ /** `null` = the plan can start. */
50308
+ blocker: ClusterUpdateBlockerSchema.nullable(),
50309
+ /** Prose for `blocker`. */
50310
+ blockerDetail: string().nullable()
50311
+ });
50312
+ var StartClusterUpdateResultSchema = object({
50313
+ accepted: boolean(),
50314
+ /** The job to follow with `getClusterUpdateJob`; `null` when refused. */
50315
+ jobId: string().nullable(),
50316
+ blocker: ClusterUpdateBlockerSchema.nullable(),
50317
+ message: string()
50318
+ });
50319
+ method(ClusterUpdateOptionsSchema, ClusterUpdatePlanSchema, { auth: "admin" }), method(ClusterUpdateOptionsSchema, StartClusterUpdateResultSchema, {
50320
+ kind: "mutation",
50321
+ auth: "admin"
50322
+ }), method(object({ jobId: string().optional() }), ClusterUpdateJobSchema.nullable(), { auth: "admin" });
50323
+ /**
49553
50324
  * Color-light cap. Coexists with `switch` (on/off) and `brightness`
49554
50325
  * (level) on the same device — `switch` toggles the bulb, `brightness`
49555
50326
  * sets the dim level, this cap sets the chromaticity.
@@ -49691,6 +50462,7 @@ var CompositionProblemSchema = object({
49691
50462
  "nested-field",
49692
50463
  "missing-field",
49693
50464
  "kind-mismatch",
50465
+ "source-kind-mismatch",
49694
50466
  "may-be-null",
49695
50467
  "source-missing",
49696
50468
  "source-unreadable",
@@ -49712,7 +50484,8 @@ var CompositionProblemSchema = object({
49712
50484
  "command-method-unknown",
49713
50485
  "command-source-unknown",
49714
50486
  "command-input-unknown",
49715
- "command-input-missing"
50487
+ "command-input-missing",
50488
+ "power-source-is-actuator"
49716
50489
  ]),
49717
50490
  path: string(),
49718
50491
  message: string()
@@ -50750,6 +51523,13 @@ var mediaPlayerCapability = {
50750
51523
  deviceNative: true,
50751
51524
  mode: "singleton",
50752
51525
  deviceTypes: [DeviceType.MediaPlayer],
51526
+ power: {
51527
+ kind: "methods",
51528
+ field: "state",
51529
+ offValues: ["off", "standby"],
51530
+ on: "turnOn",
51531
+ off: "turnOff"
51532
+ },
50753
51533
  methods: {
50754
51534
  turnOn: optionalMethod(deviceOnly, _void(), {
50755
51535
  kind: "mutation",
@@ -50880,8 +51660,8 @@ var mediaPlayerCapability = {
50880
51660
  isMuted: { kind: "boolean" }
50881
51661
  } }
50882
51662
  };
50883
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.EventEmitter, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Heater, DeviceType.Dryer, DeviceType.Gateway, DeviceType.HeatPump, DeviceType.Camera, DeviceType.Fan, DeviceType.Lock, DeviceType.Camera, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.Gateway, DeviceType.Hub, DeviceType.Camera, DeviceType.Sensor, DeviceType.MediaPlayer, DeviceType.Vacuum, DeviceType.Generic, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.PetFountain, DeviceType.Fan, DeviceType.Humidifier, DeviceType.Climate, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.MediaPlayer;
50884
- var NUMBER = {
51663
+ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Climate, DeviceType.EventEmitter, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway, DeviceType.HeatPump, DeviceType.Camera, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Fan, DeviceType.Lock, DeviceType.Camera, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Gateway, DeviceType.Camera, DeviceType.Sensor, DeviceType.MediaPlayer, DeviceType.Vacuum, DeviceType.Generic, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.PetFountain, DeviceType.Fan, DeviceType.Humidifier, DeviceType.Climate, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.PetFeeder, DeviceType.MediaPlayer;
51664
+ var NUMBER$1 = {
50885
51665
  kind: "fixed",
50886
51666
  result: ["number"]
50887
51667
  };
@@ -50890,19 +51670,19 @@ var BOOLEAN = {
50890
51670
  result: ["boolean"]
50891
51671
  };
50892
51672
  Object.freeze({
50893
- min: NUMBER,
50894
- max: NUMBER,
50895
- abs: NUMBER,
50896
- floor: NUMBER,
50897
- ceil: NUMBER,
50898
- sqrt: NUMBER,
50899
- round: NUMBER,
50900
- pow: NUMBER,
50901
- clamp: NUMBER,
50902
- avg: NUMBER,
50903
- sum: NUMBER,
50904
- age: NUMBER,
50905
- convert: NUMBER,
51673
+ min: NUMBER$1,
51674
+ max: NUMBER$1,
51675
+ abs: NUMBER$1,
51676
+ floor: NUMBER$1,
51677
+ ceil: NUMBER$1,
51678
+ sqrt: NUMBER$1,
51679
+ round: NUMBER$1,
51680
+ pow: NUMBER$1,
51681
+ clamp: NUMBER$1,
51682
+ avg: NUMBER$1,
51683
+ sum: NUMBER$1,
51684
+ age: NUMBER$1,
51685
+ convert: NUMBER$1,
50906
51686
  coalesce: { kind: "coalesce" },
50907
51687
  any: BOOLEAN,
50908
51688
  all: BOOLEAN,
@@ -50919,7 +51699,7 @@ Object.freeze({
50919
51699
  kind: "fixed",
50920
51700
  result: ["number", "null"]
50921
51701
  },
50922
- count: NUMBER,
51702
+ count: NUMBER$1,
50923
51703
  dwell: {
50924
51704
  kind: "fixed",
50925
51705
  result: ["boolean", "null"]
@@ -50945,7 +51725,7 @@ var CHANGED = {
50945
51725
  fieldPath: "lastChangedAt",
50946
51726
  expr: "now"
50947
51727
  };
50948
- function numeric(input) {
51728
+ function numeric$1(input) {
50949
51729
  const { alsoFrom, ...base } = input;
50950
51730
  return {
50951
51731
  ...base,
@@ -50963,7 +51743,7 @@ function numeric(input) {
50963
51743
  }, ...alsoFrom === void 0 ? [] : [alsoFrom.accept]]
50964
51744
  };
50965
51745
  }
50966
- var CLIMATE_DEVICES = [DeviceType.Thermostat, DeviceType.Climate];
51746
+ var CLIMATE_DEVICES = [DeviceType.Climate];
50967
51747
  function binary(input) {
50968
51748
  return {
50969
51749
  ...input,
@@ -50980,7 +51760,7 @@ function binary(input) {
50980
51760
  };
50981
51761
  }
50982
51762
  var GROUP_TEMPLATES = [
50983
- numeric({
51763
+ numeric$1({
50984
51764
  id: "temperature",
50985
51765
  summary: "Mean, median, min, max or sum of temperatures. Offline and unknown members are left out, never read as 0 °C.",
50986
51766
  role: DeviceRole.TemperatureSensor,
@@ -50998,7 +51778,7 @@ var GROUP_TEMPLATES = [
50998
51778
  }
50999
51779
  }
51000
51780
  }),
51001
- numeric({
51781
+ numeric$1({
51002
51782
  id: "humidity",
51003
51783
  summary: "Humidity in percent. Offline and unknown members are left out, never read as 0 %.",
51004
51784
  role: DeviceRole.HumiditySensor,
@@ -51016,7 +51796,7 @@ var GROUP_TEMPLATES = [
51016
51796
  }
51017
51797
  }
51018
51798
  }),
51019
- numeric({
51799
+ numeric$1({
51020
51800
  id: "pressure",
51021
51801
  summary: "Pressure in hectopascals. Offline and unknown members are left out, never read as 0 hPa.",
51022
51802
  role: DeviceRole.PressureSensor,
@@ -51027,7 +51807,7 @@ var GROUP_TEMPLATES = [
51027
51807
  empty: "null",
51028
51808
  companions: [NOW$3]
51029
51809
  }),
51030
- numeric({
51810
+ numeric$1({
51031
51811
  id: "illuminance",
51032
51812
  summary: "Illuminance in lux. Offline and unknown members are left out, never read as 0 lx.",
51033
51813
  role: DeviceRole.AmbientLightSensor,
@@ -51038,7 +51818,7 @@ var GROUP_TEMPLATES = [
51038
51818
  empty: "null",
51039
51819
  companions: [NOW$3]
51040
51820
  }),
51041
- numeric({
51821
+ numeric$1({
51042
51822
  id: "power",
51043
51823
  summary: "Power in watts, summed by default. Nobody reporting omits the key rather than writing 0 W.",
51044
51824
  role: DeviceRole.PowerSensor,
@@ -51049,7 +51829,7 @@ var GROUP_TEMPLATES = [
51049
51829
  empty: "omit",
51050
51830
  companions: [NOW$3]
51051
51831
  }),
51052
- numeric({
51832
+ numeric$1({
51053
51833
  id: "energy",
51054
51834
  summary: "Energy in kilowatt-hours, summed by default. Nobody reporting omits the key rather than writing 0 kWh.",
51055
51835
  role: DeviceRole.EnergySensor,
@@ -51060,7 +51840,7 @@ var GROUP_TEMPLATES = [
51060
51840
  empty: "omit",
51061
51841
  companions: [NOW$3]
51062
51842
  }),
51063
- numeric({
51843
+ numeric$1({
51064
51844
  id: "battery",
51065
51845
  summary: "Battery charge in percent. Offline and unknown members are left out, never read as 0 %.",
51066
51846
  role: DeviceRole.BatterySensor,
@@ -51084,7 +51864,7 @@ var GROUP_TEMPLATES = [
51084
51864
  }
51085
51865
  ]
51086
51866
  }),
51087
- numeric({
51867
+ numeric$1({
51088
51868
  id: "numeric",
51089
51869
  summary: "A numeric reading in the members’ shared quantity. Mixed quantities are refused. Nobody reporting publishes null, not 0.",
51090
51870
  role: DeviceRole.NumericSensor,
@@ -51285,8 +52065,8 @@ var GROUP_TEMPLATES = [
51285
52065
  actuator({
51286
52066
  id: "climate",
51287
52067
  summary: "Running when at least one thermostat or air conditioner is not off, or only when all of them are. Target temperature and fan are offered only when every device has them. A device that reports nothing is left out, never read as off.",
51288
- deviceType: DeviceType.Thermostat,
51289
- memberDeviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
52068
+ deviceType: DeviceType.Climate,
52069
+ memberDeviceTypes: [DeviceType.Climate],
51290
52070
  outputCap: "group-active",
51291
52071
  outputField: "active",
51292
52072
  member: {
@@ -51329,6 +52109,118 @@ function actuator(input) {
51329
52109
  new Map(GROUP_TEMPLATES.map((template) => [template.id, template]));
51330
52110
  DeviceFeature.LightColorRgb, DeviceFeature.LightColorHsv, DeviceFeature.LightColorMired;
51331
52111
  DeviceType.Camera;
52112
+ var PowerSourceRefusalReasonSchema = _enum([
52113
+ "no-power-source",
52114
+ "switch-missing",
52115
+ "switch-offline",
52116
+ "switch-unknown",
52117
+ "restart-in-progress",
52118
+ "switch-call-failed",
52119
+ "switch-is-actuator"
52120
+ ]);
52121
+ var PowerSourceActionResultSchema = discriminatedUnion("accepted", [object({ accepted: literal(true) }), object({
52122
+ accepted: literal(false),
52123
+ reason: PowerSourceRefusalReasonSchema,
52124
+ /** One line for the operator, naming the switch. */
52125
+ message: string()
52126
+ })]);
52127
+ var PowerSourceInfoSchema = object({
52128
+ /** False when the device has no power source linked; the rest is then empty. */
52129
+ linked: boolean(),
52130
+ switchDeviceId: number$1().nullable(),
52131
+ switchName: string().nullable(),
52132
+ /** The switch's on/off state, or null when it is unknown. Never false for unknown (D393). */
52133
+ switchOn: boolean().nullable(),
52134
+ switchOnline: boolean().nullable(),
52135
+ restartInProgress: boolean(),
52136
+ offSeconds: number$1().int()
52137
+ });
52138
+ /** The composed slice: the switch state the link mirrors, plus the stored off time. */
52139
+ var PowerSourceStateSchema = object({
52140
+ /** Null while the switch has not reported: unknown is never `false` (D393). */
52141
+ switchOn: boolean().nullable(),
52142
+ offSeconds: number$1().int(),
52143
+ lastFetchedAt: number$1()
52144
+ });
52145
+ var POWER_SOURCE_DEVICE_TYPES = [
52146
+ DeviceType.Gateway,
52147
+ DeviceType.Camera,
52148
+ DeviceType.Sensor,
52149
+ DeviceType.MediaPlayer,
52150
+ DeviceType.Vacuum,
52151
+ DeviceType.Generic,
52152
+ DeviceType.Washer,
52153
+ DeviceType.Dryer,
52154
+ DeviceType.Dishwasher,
52155
+ DeviceType.PetFountain,
52156
+ DeviceType.Fan,
52157
+ DeviceType.Humidifier,
52158
+ DeviceType.Climate,
52159
+ DeviceType.WaterHeater
52160
+ ];
52161
+ var powerSourceCustomize = {
52162
+ version: 1,
52163
+ targets: [{
52164
+ id: "switch",
52165
+ cardinality: "scalar",
52166
+ vocabulary: {
52167
+ cap: "power-source",
52168
+ stateField: "switchOn"
52169
+ },
52170
+ deviceTypes: POWER_SOURCE_DEVICE_TYPES,
52171
+ apply: "add",
52172
+ path: "switchOn",
52173
+ sources: [{
52174
+ kind: "switch",
52175
+ transforms: [{ kind: "identity" }]
52176
+ }],
52177
+ companions: [{
52178
+ kind: "constant",
52179
+ path: "offSeconds",
52180
+ value: 10,
52181
+ when: "add",
52182
+ formerValues: [0]
52183
+ }, {
52184
+ kind: "clock",
52185
+ path: "lastFetchedAt",
52186
+ when: "always"
52187
+ }],
52188
+ sourceReport: { status: "assignment-status" }
52189
+ }]
52190
+ };
52191
+ var deviceInput$2 = object({ deviceId: number$1().int().nonnegative() });
52192
+ var powerSourceCapability = {
52193
+ name: "power-source",
52194
+ scope: "device",
52195
+ mode: "singleton",
52196
+ deviceTypes: POWER_SOURCE_DEVICE_TYPES,
52197
+ methods: {
52198
+ getStatus: systemMethod(deviceInput$2, PowerSourceInfoSchema, { auth: "protected" }),
52199
+ powerOn: systemMethod(deviceInput$2, PowerSourceActionResultSchema, {
52200
+ kind: "mutation",
52201
+ auth: "protected"
52202
+ }),
52203
+ powerOff: systemMethod(deviceInput$2, PowerSourceActionResultSchema, {
52204
+ kind: "mutation",
52205
+ auth: "protected"
52206
+ }),
52207
+ forceRestart: systemMethod(deviceInput$2, PowerSourceActionResultSchema, {
52208
+ kind: "mutation",
52209
+ auth: "protected"
52210
+ })
52211
+ },
52212
+ runtimeState: PowerSourceStateSchema,
52213
+ customize: powerSourceCustomize,
52214
+ /**
52215
+ * Runtime-state durability: **session** — mirrored from the linked switch on
52216
+ * its next report; a restored value would outlive the reading it names.
52217
+ *
52218
+ * See `RuntimeStateDurability`. Enforced by
52219
+ * `scripts/check-runtime-state-durability.ts`.
52220
+ */
52221
+ durability: "session",
52222
+ history: "none"
52223
+ };
51332
52224
  `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
51333
52225
  /**
51334
52226
  * What a composition may BUILD on a device type (G2, D676): the caps a new
@@ -52373,27 +53265,35 @@ var deviceAdminLinkCapability = {
52373
53265
  getAdminLink: method(object({ deviceId: number$1().int().nonnegative() }), DeviceAdminLinkSchema.nullable(), { auth: "admin" }) }
52374
53266
  };
52375
53267
  /**
52376
- * device-discovery — device-scoped capability for parents that host /
52377
- * enumerate child devices (Reolink Hub / NVR, ONVIF gateway, future
52378
- * integrations). The parent advertises a list of discoverable children,
52379
- * and the operator promotes a subset to real devices via `adoptDevice`.
52380
- * Child lifecycle (kernel device create/remove, parent reference) is
52381
- * owned by the framework so a single shared `<DiscoveryPanel />` can
52382
- * drive any integration.
52383
- *
52384
- * Boundaries:
52385
- * - The cap exposes ENUMERATION + ADOPT/RELEASE only. The protocol
52386
- * used to discover children (CGI poll, mDNS, push from hub, ...)
52387
- * is the integration's business; results land in `runtimeState`
52388
- * so the panel can render without a fresh round-trip.
52389
- * - `childNativeId` is integration-defined and must be stable across
52390
- * reboots so re-discovery doesn't duplicate adopted children.
52391
- * - `adoptDevice` returns the new `deviceId` so the UI can navigate
52392
- * directly to the freshly-created child's settings page.
52393
- */
52394
- /**
52395
- * Status enum for a discovered device. Mirrors the Reolink simpleEvent
52396
- * lifecycle (`online` / `sleeping` / `offline`) — generic enough for
53268
+ * `device-adoption` — generic discovery + adoption surface,
53269
+ * system-scoped collection (one provider per integration addon), routed
53270
+ * by explicit `{ addonId }` on direct `deviceAdoption.*` calls — the same
53271
+ * pin model as `connection-test`. The admin UI keeps using
53272
+ * `deviceManager.adoption*` (which already threads `addonId`). The de-HA-ified
53273
+ * successor to `ha-discovery`:
53274
+ * keyed by `integrationId` (the addon resolves integrationId → broker
53275
+ * internally) rather than a connection-specific `brokerId`, and it
53276
+ * OWNS the shared `DiscoveredChildDevice` candidate type
53277
+ * (declared below) so a single shared adoption modal renders any integration's
53278
+ * candidate tree.
53279
+ *
53280
+ * UI flow (Integrations page → integration detail):
53281
+ * 1. `listCandidates({integrationId, page, pageSize, filter})` →
53282
+ * paginated candidate list, each row collapsed.
53283
+ * 2. User expands a row → `getCandidate({integrationId,
53284
+ * childNativeId})` returns the single candidate (with its nested
53285
+ * `children`) for the accordion render.
53286
+ * 3. User clicks "Adopt" on one or more candidates →
53287
+ * `adopt({integrationId, childNativeIds, perCandidate?})`
53288
+ * materialises one parent CamStack device per candidate plus its
53289
+ * accessory children. `perCandidate[childNativeId]` carries an
53290
+ * optional display-name override + a `hiddenChildIds` pre-hide set
53291
+ * (the parent's `accessories.hiddenChildIds` gates UI visibility).
53292
+ * 4. User clicks "Release" → `release({integrationId, camDeviceId})`
53293
+ * removes the parent + every child from the kernel registry.
53294
+ */
53295
+ /**
53296
+ * Status enum for a discovered device. Generic enough for
52397
53297
  * battery cams, awake cams, and unreachable cams alike.
52398
53298
  */
52399
53299
  var DiscoveredChildStatusSchema = _enum([
@@ -52410,12 +53310,12 @@ var DiscoveredChildDeviceSchema = lazy(() => object({
52410
53310
  /** DeviceType the child should be created with on adopt. */
52411
53311
  type: _enum(DeviceType),
52412
53312
  status: DiscoveredChildStatusSchema,
52413
- /** Free-form integration-specific metadata surfaced in the panel. */
53313
+ /** Free-form integration-specific metadata surfaced in the adoption modal. */
52414
53314
  metadata: object({
52415
53315
  model: string().optional(),
52416
53316
  serialNumber: string().optional(),
52417
53317
  uid: string().optional(),
52418
- /** Reolink: 0-based channel index inside the parent NVR/Hub. */
53318
+ /** Reolink: 0-based channel index inside the parent NVR. */
52419
53319
  rtspChannel: number$1().int().nonnegative().optional(),
52420
53320
  isBattery: boolean().optional(),
52421
53321
  isDoorbell: boolean().optional(),
@@ -52431,137 +53331,24 @@ var DiscoveredChildDeviceSchema = lazy(() => object({
52431
53331
  }).default({}),
52432
53332
  /**
52433
53333
  * `true` when the framework already created a child device for this
52434
- * `childNativeId` under the current parent. The panel uses it to
53334
+ * `childNativeId` under the current parent. The adoption modal uses it to
52435
53335
  * gate the Add/Remove button and surface the existing `deviceId`.
52436
53336
  */
52437
53337
  alreadyAdopted: boolean(),
52438
53338
  /** When `alreadyAdopted=true`, the framework-assigned child device id. */
52439
53339
  adoptedDeviceId: number$1().int().nonnegative().nullable(),
52440
- /** Capability names the candidate advertises — integrations populate, Hubs omit. */
53340
+ /** Capability names the candidate advertises — integrations populate, flat enumerations omit. */
52441
53341
  capabilities: array(string()).readonly().optional(),
52442
- /** Feature flags the candidate advertises — integrations populate, Hubs omit. */
53342
+ /** Feature flags the candidate advertises — integrations populate, flat enumerations omit. */
52443
53343
  features: array(string()).readonly().optional(),
52444
53344
  /** Upstream-system identity + rendering hints for the candidate. */
52445
53345
  sourceInfo: SourceInfoSchema.optional(),
52446
- /** Nested entity-children for accordion preview — integrations populate, Hubs omit. */
53346
+ /** Nested entity-children for accordion preview — integrations populate, flat enumerations omit. */
52447
53347
  children: array(DiscoveredChildDeviceSchema).readonly().optional(),
52448
53348
  /** `childNativeId` of the gateway candidate in the same list this device sits behind. */
52449
53349
  parentStableId: string().optional()
52450
53350
  }));
52451
53351
  /**
52452
- * Status block — populated by the integration after each
52453
- * `refreshDiscovery` (or push-driven update). The runtime-state slice
52454
- * is what the panel reads via the per-device proxy; `getStatus`
52455
- * returns the same payload via the codegen'd auto-injection.
52456
- */
52457
- var DeviceDiscoveryStatusSchema = object({
52458
- discovered: array(DiscoveredChildDeviceSchema),
52459
- /** Wall-clock ms of the last successful enumeration. */
52460
- lastDiscoveryAt: number$1().int().nonnegative().nullable(),
52461
- /** Last error surfaced from the source (rendered as a banner). */
52462
- lastError: string().nullable()
52463
- });
52464
- var deviceDiscoveryCapability = {
52465
- name: "device-discovery",
52466
- scope: "device",
52467
- deviceNative: true,
52468
- mode: "singleton",
52469
- deviceTypes: [DeviceType.Hub],
52470
- status: {
52471
- schema: DeviceDiscoveryStatusSchema,
52472
- kind: "poll"
52473
- },
52474
- runtimeState: DeviceDiscoveryStatusSchema.extend({ lastFetchedAt: number$1().int().nonnegative() }),
52475
- /**
52476
- * Runtime-state durability: **session** — 5.7 KB of scan output on the largest device, fully re-derivable by re-scanning.
52477
- *
52478
- * See `RuntimeStateDurability`. Enforced by
52479
- * `scripts/check-runtime-state-durability.ts`.
52480
- */
52481
- durability: "session",
52482
- /**
52483
- * History (D850): none — inventory.
52484
- */
52485
- history: "none",
52486
- methods: {
52487
- /**
52488
- * Snapshot of the current `discovered` list. Returns the
52489
- * runtime-state cache — call `refreshDiscovery` first if a
52490
- * fresh round-trip to the source is required.
52491
- */
52492
- listDiscovered: method(object({ deviceId: number$1().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly()),
52493
- /**
52494
- * Force the integration to re-enumerate and update the
52495
- * runtime-state slice. Returns the freshly-enumerated list (also
52496
- * available via `listDiscovered` post-call).
52497
- */
52498
- refreshDiscovery: method(object({ deviceId: number$1().int().nonnegative() }), array(DiscoveredChildDeviceSchema).readonly(), {
52499
- kind: "mutation",
52500
- auth: "admin"
52501
- }),
52502
- /**
52503
- * Promote a discovered entry to a real child device. The framework
52504
- * creates the child via `kernel.devices.create()` with
52505
- * `parentDeviceId = parent.id` and seeds the child's config from
52506
- * `childInitialConfig` (driver-defined; usually carries channel +
52507
- * uid + parent reference). Returns the kernel-assigned numeric id.
52508
- */
52509
- adoptDevice: method(object({
52510
- deviceId: number$1().int().nonnegative(),
52511
- childNativeId: string(),
52512
- /** Optional override for the child's display name. */
52513
- name: string().optional()
52514
- }), object({
52515
- deviceId: number$1().int().nonnegative(),
52516
- stableId: string()
52517
- }), {
52518
- kind: "mutation",
52519
- auth: "admin"
52520
- }),
52521
- /**
52522
- * Inverse of `adoptDevice`: removes the child device from the
52523
- * kernel registry. The discovered entry remains in the
52524
- * enumeration (status updates resume) so the operator can re-adopt
52525
- * it later without a fresh refresh.
52526
- */
52527
- releaseDevice: method(object({
52528
- deviceId: number$1().int().nonnegative(),
52529
- childDeviceId: number$1().int().nonnegative()
52530
- }), _void(), {
52531
- kind: "mutation",
52532
- auth: "admin"
52533
- })
52534
- }
52535
- };
52536
- /**
52537
- * `device-adoption` — generic discovery + adoption surface,
52538
- * system-scoped collection (one provider per integration addon), routed
52539
- * by explicit `{ addonId }` on direct `deviceAdoption.*` calls — the same
52540
- * pin model as `connection-test`. The admin UI keeps using
52541
- * `deviceManager.adoption*` (which already threads `addonId`). The de-HA-ified
52542
- * successor to `ha-discovery`:
52543
- * keyed by `integrationId` (the addon resolves integrationId → broker
52544
- * internally) rather than a connection-specific `brokerId`, and it
52545
- * REUSES the shared `DiscoveredChildDevice` candidate type from
52546
- * `device-discovery.cap.ts` so a single shared adoption panel renders
52547
- * any integration's candidate tree.
52548
- *
52549
- * UI flow (Integrations page → integration detail):
52550
- * 1. `listCandidates({integrationId, page, pageSize, filter})` →
52551
- * paginated candidate list, each row collapsed.
52552
- * 2. User expands a row → `getCandidate({integrationId,
52553
- * childNativeId})` returns the single candidate (with its nested
52554
- * `children`) for the accordion render.
52555
- * 3. User clicks "Adopt" on one or more candidates →
52556
- * `adopt({integrationId, childNativeIds, perCandidate?})`
52557
- * materialises one parent CamStack device per candidate plus its
52558
- * accessory children. `perCandidate[childNativeId]` carries an
52559
- * optional display-name override + a `hiddenChildIds` pre-hide set
52560
- * (the parent's `accessories.hiddenChildIds` gates UI visibility).
52561
- * 4. User clicks "Release" → `release({integrationId, camDeviceId})`
52562
- * removes the parent + every child from the kernel registry.
52563
- */
52564
- /**
52565
53352
  * Provider-declared discovery *granularity* filter (the keystone of the
52566
53353
  * single-entity-import design). A provider advertises which discovery
52567
53354
  * granularities it supports via `listCandidateFilters`; the operator picks
@@ -53022,6 +53809,90 @@ var SiteZoneSchema = object({
53022
53809
  * the ones that most want grouping. */
53023
53810
  locationKeys: array(string())
53024
53811
  });
53812
+ var DeviceSearchRequiresSchema = object({
53813
+ /** Capability names the field wants a value of. */
53814
+ caps: array(string()).readonly(),
53815
+ /** `any` (default): a device with one of them is compatible. `all`: it must hold every one. */
53816
+ mode: _enum(["any", "all"]).optional()
53817
+ });
53818
+ var DeviceSearchFiltersSchema = object({
53819
+ locations: array(string()).readonly().optional(),
53820
+ integrationIds: array(string()).readonly().optional(),
53821
+ types: array(string()).readonly().optional()
53822
+ });
53823
+ var DeviceSearchInputSchema = object({
53824
+ /** Matches name, location, type, integration (id and label) and id. Case-insensitive. */
53825
+ text: string().optional(),
53826
+ requires: DeviceSearchRequiresSchema.optional(),
53827
+ filters: DeviceSearchFiltersSchema.optional(),
53828
+ /** List the devices that do not provide the required cap too (greyed, not pickable). */
53829
+ includeIncompatible: boolean().optional(),
53830
+ /** The device the field belongs to: its location drives `suggested`. */
53831
+ contextDeviceId: number$1().optional(),
53832
+ /**
53833
+ * Resolve exactly these devices, regardless of every filter, so a picked
53834
+ * value can be rendered. When given, `items` is these devices in the order
53835
+ * asked (no paging, `nextCursor` null) and the ones that do not exist are
53836
+ * listed in `missingIds`.
53837
+ */
53838
+ ids: array(number$1()).readonly().optional(),
53839
+ cursor: string().nullable().optional(),
53840
+ limit: number$1().int().min(1).max(200).optional()
53841
+ });
53842
+ var DeviceValueOptionSchema = object({
53843
+ cap: string(),
53844
+ /** Status-schema field path inside the cap (same paths as `getWireableFields`). */
53845
+ path: string(),
53846
+ label: string(),
53847
+ /** The current value as text; `null` when it is unknown or the read FAILED — never `0`. */
53848
+ reading: string().nullable(),
53849
+ unit: string().nullable().optional(),
53850
+ /** The field's measured quantity, when the cap declares one, so a client can format the reading. */
53851
+ quantity: ReadingQuantitySchema.optional()
53852
+ });
53853
+ var DeviceSearchItemSchema = object({
53854
+ id: number$1(),
53855
+ name: string(),
53856
+ type: _enum(DeviceType),
53857
+ location: string().nullable(),
53858
+ integrationId: string().nullable(),
53859
+ integrationLabel: string().nullable(),
53860
+ online: boolean().nullable(),
53861
+ disabled: boolean(),
53862
+ compatible: boolean(),
53863
+ /** Only the required caps' fields; empty when nothing was required or the device is incompatible. */
53864
+ values: array(DeviceValueOptionSchema).readonly()
53865
+ });
53866
+ var DeviceSearchFacetValueSchema = object({
53867
+ value: string(),
53868
+ count: number$1()
53869
+ });
53870
+ var DeviceSearchFacetIntegrationSchema = object({
53871
+ id: string(),
53872
+ label: string(),
53873
+ count: number$1()
53874
+ });
53875
+ var DeviceSearchFacetsSchema = object({
53876
+ locations: array(DeviceSearchFacetValueSchema).readonly(),
53877
+ integrations: array(DeviceSearchFacetIntegrationSchema).readonly(),
53878
+ types: array(DeviceSearchFacetValueSchema).readonly()
53879
+ });
53880
+ var DeviceSearchResultSchema = object({
53881
+ items: array(DeviceSearchItemSchema).readonly(),
53882
+ nextCursor: string().nullable(),
53883
+ /** Devices listed by this query across all pages (filters + text applied; incompatible counted only when `includeIncompatible`). */
53884
+ total: number$1(),
53885
+ /** Compatible devices after filters + text. */
53886
+ compatibleTotal: number$1(),
53887
+ /** Every device the caller's fleet holds, before anything is applied. */
53888
+ fleetTotal: number$1(),
53889
+ /** Incompatible devices after filters + text — the K of "show the K that don't". */
53890
+ hiddenIncompatible: number$1(),
53891
+ facets: DeviceSearchFacetsSchema,
53892
+ /** Same location as `contextDeviceId`; first page only. */
53893
+ suggested: array(DeviceSearchItemSchema).readonly(),
53894
+ missingIds: array(number$1()).readonly()
53895
+ });
53025
53896
  var ProviderStatusSchema = object({
53026
53897
  connected: boolean(),
53027
53898
  deviceCount: number$1(),
@@ -53507,6 +54378,9 @@ var DeviceInfoSchema = object({
53507
54378
  * named accordion sections (with optional intra-section order). See
53508
54379
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
53509
54380
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
54381
+ /** The children a container stands for, most preferred first, by accessory
54382
+ * `childKey`. Provider-declared. See `DeviceMeta.primaryChildPreference`. */
54383
+ primaryChildPreference: array(string()).readonly().optional(),
53510
54384
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
53511
54385
  display: DeviceDisplayOverrideSchema.optional()
53512
54386
  });
@@ -53655,6 +54529,9 @@ var DeviceMetaSchema = object({
53655
54529
  * accordion sections (with optional intra-section order). See
53656
54530
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
53657
54531
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
54532
+ /** The children a container stands for, most preferred first, by accessory
54533
+ * `childKey`. Provider-declared. See `DeviceMeta.primaryChildPreference`. */
54534
+ primaryChildPreference: array(string()).readonly().optional(),
53658
54535
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
53659
54536
  * Optional: only present for accessory children that carry a known role. */
53660
54537
  role: string().nullable().optional(),
@@ -53870,6 +54747,12 @@ method(object({
53870
54747
  }), _void(), {
53871
54748
  kind: "mutation",
53872
54749
  auth: "admin"
54750
+ }), method(object({
54751
+ deviceId: number$1(),
54752
+ primaryChildPreference: array(string()).readonly()
54753
+ }), _void(), {
54754
+ kind: "mutation",
54755
+ auth: "admin"
53873
54756
  }), method(object({
53874
54757
  deviceId: number$1(),
53875
54758
  display: DeviceDisplayOverrideSchema.nullable()
@@ -53893,8 +54776,14 @@ method(object({
53893
54776
  "string",
53894
54777
  "number",
53895
54778
  "boolean",
53896
- "enum"
54779
+ "enum",
54780
+ "union"
53897
54781
  ]),
54782
+ unionMembers: array(_enum([
54783
+ "string",
54784
+ "number",
54785
+ "boolean"
54786
+ ])).optional(),
53898
54787
  enumValues: array(string()).optional(),
53899
54788
  item: boolean().optional(),
53900
54789
  quantity: ReadingQuantitySchema.optional()
@@ -54007,7 +54896,7 @@ method(object({
54007
54896
  * it answers today and the caller filters as it already does.
54008
54897
  */
54009
54898
  deviceIds: array(number$1()).optional()
54010
- }), array(DeviceInfoSchema), { role: "viewer" }), method(object({ deviceId: number$1() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number$1() }), array(DeviceInfoSchema), { role: "viewer" }), method(object({ parentDeviceIds: array(number$1()).max(256) }), record(string(), array(DeviceInfoSchema))), method(object({ deviceId: number$1() }), object({
54899
+ }), array(DeviceInfoSchema), { role: "viewer" }), method(DeviceSearchInputSchema, DeviceSearchResultSchema, { role: "viewer" }), method(object({ deviceId: number$1() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number$1() }), array(DeviceInfoSchema), { role: "viewer" }), method(object({ parentDeviceIds: array(number$1()).max(256) }), record(string(), array(DeviceInfoSchema))), method(object({ deviceId: number$1() }), object({
54011
54900
  mode: LinkedDevicesModeSchema,
54012
54901
  devices: array(LinkedDeviceSchema)
54013
54902
  }), { role: "viewer" }), method(object({ deviceIds: array(number$1()) }), array(LinkedDevicesForDeviceSchema), { role: "viewer" }), method(object({ deviceId: number$1() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number$1() }), array(ConfigEntrySchema)), method(object({ deviceId: number$1() }), ConfigUISchemaOutput), method(object({
@@ -55986,53 +56875,6 @@ var enumSensorCapability = {
55986
56875
  * whether persisting is worth a SQLite commit. */
55987
56876
  volatileStateFields: ["lastFetchedAt", "timestamp"]
55988
56877
  };
55989
- /**
55990
- * Generic stateless event emitter. Installed on a `DeviceType.EventEmitter`
55991
- * device. Carries the device's EXACT declared event vocabulary verbatim
55992
- * (NO normalization). Two shapes:
55993
- * - structured source (HA `event.*` entity) → `eventTypes` is the fixed
55994
- * declared vocabulary (e.g. ['press_short','press_long',...]).
55995
- * - generic/legacy source (HA bus events, e.g. zha_event) → `eventTypes`
55996
- * is [] (open) and `lastEvent.data` carries the complex payload.
55997
- * `seq` is monotonic so two identical events still produce distinct slice writes.
55998
- */
55999
- var EventFireSchema = object({
56000
- deviceId: number$1(),
56001
- eventType: string(),
56002
- data: record(string(), unknown()).nullable(),
56003
- timestamp: number$1().meta(readingMeta("none")),
56004
- seq: number$1().meta(readingMeta("count"))
56005
- });
56006
- var EventEmitterStatusSchema = object({
56007
- eventTypes: array(string()),
56008
- lastEvent: EventFireSchema.nullable(),
56009
- eventCountSinceStart: number$1().meta(readingMeta("count"))
56010
- });
56011
- var eventEmitterCapability = {
56012
- name: "event-emitter",
56013
- scope: "device",
56014
- deviceNative: true,
56015
- mode: "singleton",
56016
- deviceTypes: [DeviceType.EventEmitter],
56017
- methods: {},
56018
- events: { onEvent: { data: EventFireSchema } },
56019
- status: {
56020
- schema: EventEmitterStatusSchema,
56021
- kind: "push"
56022
- },
56023
- runtimeState: EventEmitterStatusSchema,
56024
- /**
56025
- * Runtime-state durability: **session** — `eventCountSinceStart` names its own scope.
56026
- *
56027
- * See `RuntimeStateDurability`. Enforced by
56028
- * `scripts/check-runtime-state-durability.ts`.
56029
- */
56030
- durability: "session",
56031
- /**
56032
- * History (D850): none — `eventCountSinceStart` resets with the addon; events live in ledgers.
56033
- */
56034
- history: "none"
56035
- };
56036
56878
  var IdentitySchema = object({
56037
56879
  id: string(),
56038
56880
  name: string(),
@@ -56522,8 +57364,85 @@ method(_void(), array(FailureContributionSchema).readonly());
56522
57364
  * - `FanPreset` — preset selector
56523
57365
  * - `FanDirection` — direction toggle (ceiling fans)
56524
57366
  * - `FanOscillating` — oscillation toggle
57367
+ *
57368
+ * Per-axis oscillation (`oscillationAxes`, `setOscillationAxis`, D1034): an air
57369
+ * circulator sweeps horizontally and vertically, each axis with its own
57370
+ * switch, fixed angle, sweep bounds and limits. A provider may report them
57371
+ * natively; otherwise the OPERATOR links sibling devices to each field in
57372
+ * Customize (a `replace` over the fields listed in `overridable` — nothing is
57373
+ * linked or inferred by itself), and a write to a linked field is forwarded to
57374
+ * its source. `oscillating` stays the single summary flag: when axes exist it
57375
+ * means "any axis oscillates" (`anyAxisOscillating`).
56525
57376
  */
56526
57377
  var FanDirectionSchema = _enum(["forward", "reverse"]);
57378
+ var FAN_OSCILLATION_AXES = ["horizontal", "vertical"];
57379
+ var FanOscillationAxisNameSchema = _enum(FAN_OSCILLATION_AXES);
57380
+ var FanOscillationAxisFieldSchema = _enum([
57381
+ "oscillating",
57382
+ "angle",
57383
+ "sweepFrom",
57384
+ "sweepTo"
57385
+ ]);
57386
+ /** The limits of one axis, read from the source that serves its angle. */
57387
+ var FAN_OSCILLATION_AXIS_LIMITS = [
57388
+ "min",
57389
+ "max",
57390
+ "step"
57391
+ ];
57392
+ /**
57393
+ * One oscillation axis. Every reading the device has not reported is `null`,
57394
+ * never `0` (D393): `0°` is a real position. Each leaf defaults to `null`, so
57395
+ * a slice that names only the leaves it knows (a Customize link of one field)
57396
+ * is still a whole axis.
57397
+ */
57398
+ var FanOscillationAxisSchema = object({
57399
+ /** Whether the axis sweeps. */
57400
+ oscillating: boolean().nullable().default(null),
57401
+ /** The fixed position (degrees) the axis holds when it does not sweep. */
57402
+ angle: number$1().nullable().default(null),
57403
+ /** The sweep bounds (degrees): left → right, bottom → top. */
57404
+ sweepFrom: number$1().nullable().default(null),
57405
+ sweepTo: number$1().nullable().default(null),
57406
+ /** The angles the axis accepts. Null when the device states none. */
57407
+ min: number$1().nullable().default(null),
57408
+ max: number$1().nullable().default(null),
57409
+ step: number$1().nullable().default(null)
57410
+ });
57411
+ /** Both axes; an axis the device does not have is `null`. */
57412
+ var FanOscillationAxesSchema = object({
57413
+ horizontal: FanOscillationAxisSchema.nullable().default(null),
57414
+ vertical: FanOscillationAxisSchema.nullable().default(null)
57415
+ });
57416
+ var FanOscillationAxisRefusalSchema = _enum([
57417
+ "nothing-to-set",
57418
+ "axis-not-linked",
57419
+ "out-of-range",
57420
+ "source-gone",
57421
+ "source-unknown",
57422
+ "source-call-failed"
57423
+ ]);
57424
+ var FanOscillationAxisResultSchema = discriminatedUnion("status", [object({ status: literal("applied") }), object({
57425
+ status: literal("refused"),
57426
+ reason: FanOscillationAxisRefusalSchema,
57427
+ /** The field at fault, when one is. */
57428
+ field: FanOscillationAxisFieldSchema.optional(),
57429
+ /** One line for the operator, naming the axis, the field and the source. */
57430
+ detail: string(),
57431
+ /** Fields that DID land before the refusal (writes run in order, stop at the first failure). */
57432
+ applied: array(FanOscillationAxisFieldSchema)
57433
+ })]);
57434
+ var SetOscillationAxisInputSchema = object({
57435
+ deviceId: number$1().int().nonnegative(),
57436
+ axis: FanOscillationAxisNameSchema,
57437
+ oscillating: boolean().optional(),
57438
+ angle: number$1().optional(),
57439
+ sweepFrom: number$1().optional(),
57440
+ sweepTo: number$1().optional()
57441
+ });
57442
+ /** The state path of one axis field: `oscillationAxes.<axis>.<field>`. */
57443
+ function fanAxisPath(axis, field) {
57444
+ return `oscillationAxes.${axis}.${field}`;
57445
+ }
56527
57446
  var FanControlStatusSchema = object({
56528
57447
  /** Active speed as 0..100 inclusive. Null when the device has no
56529
57448
  * speed surface (single-speed fan). */
@@ -56542,9 +57461,102 @@ var FanControlStatusSchema = object({
56542
57461
  /** Oscillation toggle. Null when the device has no oscillation
56543
57462
  * surface. */
56544
57463
  oscillating: boolean().nullable(),
57464
+ /**
57465
+ * Per-axis oscillation (air circulators). Absent when the device has no
57466
+ * per-axis surface and nothing is linked in Customize.
57467
+ */
57468
+ oscillationAxes: FanOscillationAxesSchema.optional(),
56545
57469
  /** Ms epoch when the slice was last updated. */
56546
57470
  lastChangedAt: number$1()
56547
57471
  });
57472
+ var FAN_CUSTOMIZE_DEVICE_TYPES = [DeviceType.Fan];
57473
+ /** The Customize section the axis targets are listed under. */
57474
+ var AXES_SECTION = "oscillation-axes";
57475
+ function axisOscillatingTarget(axis) {
57476
+ return {
57477
+ id: `${axis}-oscillating`,
57478
+ cardinality: "scalar",
57479
+ vocabulary: {
57480
+ cap: "fan-control",
57481
+ stateField: "oscillating"
57482
+ },
57483
+ deviceTypes: FAN_CUSTOMIZE_DEVICE_TYPES,
57484
+ apply: "replace",
57485
+ section: AXES_SECTION,
57486
+ path: fanAxisPath(axis, "oscillating"),
57487
+ sources: [{
57488
+ kind: "switch",
57489
+ transforms: [{ kind: "identity" }]
57490
+ }],
57491
+ sourceReport: { status: "assignment-status" }
57492
+ };
57493
+ }
57494
+ /**
57495
+ * A numeric axis field. The `angle` link also carries the axis limits, read
57496
+ * from the SAME linked control's own range (`min`/`max`/`step`), so the limits
57497
+ * are the source's, never guessed.
57498
+ */
57499
+ function axisNumericTarget(axis, field) {
57500
+ const base = {
57501
+ id: `${axis}-${field}`,
57502
+ cardinality: "scalar",
57503
+ vocabulary: { cap: "fan-control" },
57504
+ deviceTypes: FAN_CUSTOMIZE_DEVICE_TYPES,
57505
+ apply: "replace",
57506
+ section: AXES_SECTION,
57507
+ path: fanAxisPath(axis, field),
57508
+ sources: [{
57509
+ kind: "numeric",
57510
+ transforms: [{ kind: "identity" }]
57511
+ }],
57512
+ sourceReport: { status: "assignment-status" }
57513
+ };
57514
+ if (field !== "angle") return base;
57515
+ return {
57516
+ ...base,
57517
+ companions: FAN_OSCILLATION_AXIS_LIMITS.map((limit) => ({
57518
+ kind: "source-field",
57519
+ path: fanAxisPath(axis, limit),
57520
+ field: limit
57521
+ }))
57522
+ };
57523
+ }
57524
+ var AXIS_TARGETS = FAN_OSCILLATION_AXES.flatMap((axis) => [
57525
+ axisOscillatingTarget(axis),
57526
+ axisNumericTarget(axis, "angle"),
57527
+ axisNumericTarget(axis, "sweepFrom"),
57528
+ axisNumericTarget(axis, "sweepTo")
57529
+ ]);
57530
+ /** A select (an enum control) the operator links to the fan's `preset`. */
57531
+ var PRESET_TARGET = {
57532
+ id: "preset",
57533
+ cardinality: "scalar",
57534
+ vocabulary: { cap: "fan-control" },
57535
+ deviceTypes: FAN_CUSTOMIZE_DEVICE_TYPES,
57536
+ apply: "replace",
57537
+ path: "preset",
57538
+ sources: [{
57539
+ kind: "field",
57540
+ valueKinds: ["string"],
57541
+ transforms: [{ kind: "identity" }]
57542
+ }],
57543
+ sourceReport: { status: "assignment-status" }
57544
+ };
57545
+ var FAN_CONTROL_TARGETS = [...AXIS_TARGETS, PRESET_TARGET];
57546
+ /**
57547
+ * Customize (D1034): the fan already serves `fan-control` natively, so every
57548
+ * target is a `replace` over a field listed in `overridable` — the axis leaves
57549
+ * and `preset`, nothing else. Nothing is pre-linked: the operator picks each
57550
+ * source (D356).
57551
+ */
57552
+ var fanControlCustomize = {
57553
+ version: 1,
57554
+ overridable: FAN_CONTROL_TARGETS.map((target) => ({
57555
+ path: target.path,
57556
+ by: target.id
57557
+ })),
57558
+ targets: FAN_CONTROL_TARGETS
57559
+ };
56548
57560
  var fanControlCapability = {
56549
57561
  name: "fan-control",
56550
57562
  scope: "device",
@@ -56579,6 +57591,18 @@ var fanControlCapability = {
56579
57591
  }), _void(), {
56580
57592
  kind: "mutation",
56581
57593
  auth: "protected"
57594
+ }),
57595
+ /**
57596
+ * Sets one oscillation axis: its switch, fixed angle and/or sweep bounds.
57597
+ * Fields left out are untouched. A field linked in Customize is forwarded
57598
+ * to its linked source (D1034); otherwise the native provider serves it;
57599
+ * neither → `axis-not-linked`. Ranges are checked against the axis limits
57600
+ * before anything is written; writes run in order and stop at the first
57601
+ * failure, which the result names.
57602
+ */
57603
+ setOscillationAxis: optionalMethod(SetOscillationAxisInputSchema, FanOscillationAxisResultSchema, {
57604
+ kind: "mutation",
57605
+ auth: "protected"
56582
57606
  })
56583
57607
  },
56584
57608
  status: {
@@ -56590,6 +57614,7 @@ var fanControlCapability = {
56590
57614
  * sliders read `speed` for live updates.
56591
57615
  */
56592
57616
  runtimeState: FanControlStatusSchema,
57617
+ customize: fanControlCustomize,
56593
57618
  /**
56594
57619
  * Runtime-state durability: **session** — as `brightness`.
56595
57620
  *
@@ -56609,16 +57634,33 @@ var fanControlCapability = {
56609
57634
  oscillating: { kind: "boolean" }
56610
57635
  } }
56611
57636
  };
57637
+ /** One axis as a preset wants it; an absent field is left as it is. */
57638
+ var FanPresetAxisSchema = object({
57639
+ oscillating: boolean().optional(),
57640
+ angle: number$1().optional(),
57641
+ sweepFrom: number$1().optional(),
57642
+ sweepTo: number$1().optional()
57643
+ });
57644
+ /** The fan-control state a preset sets; an absent field is left as it is. */
57645
+ var FanPresetStateSchema = object({
57646
+ speed: number$1().min(0).max(100).optional(),
57647
+ /** The fan's own preset mode (or the linked select's option). */
57648
+ preset: string().min(1).optional(),
57649
+ direction: FanDirectionSchema.optional(),
57650
+ oscillationAxes: object({
57651
+ horizontal: FanPresetAxisSchema.optional(),
57652
+ vertical: FanPresetAxisSchema.optional()
57653
+ }).optional()
57654
+ });
56612
57655
  var FanPresetSchema = object({
56613
57656
  /** Stable key; never reused after a delete within one device's list. */
56614
57657
  id: string().min(1).max(64),
56615
57658
  /** What the operator and a voice assistant call it. */
56616
57659
  name: string().trim().min(1).max(40),
56617
- steps: array(ActionStepSchema).min(1).max(20)
57660
+ state: FanPresetStateSchema
56618
57661
  });
56619
57662
  var FanPresetsRefusalSchema = _enum([
56620
- "not-sibling",
56621
- "bad-target",
57663
+ "empty-state",
56622
57664
  "duplicate-name",
56623
57665
  "duplicate-id",
56624
57666
  "too-many",
@@ -56630,72 +57672,55 @@ var FanPresetsSetResultSchema = discriminatedUnion("status", [object({
56630
57672
  }), object({
56631
57673
  status: literal("refused"),
56632
57674
  reason: FanPresetsRefusalSchema,
56633
- /** Names the preset and step at fault. */
57675
+ /** Names the preset at fault. */
56634
57676
  detail: string()
56635
57677
  })]);
56636
- var FanPresetStepResultSchema = object({
56637
- index: number$1().int().nonnegative(),
57678
+ var FanPresetFieldResultSchema = object({
57679
+ /** The state path: `speed`, `preset`, `direction`, `oscillationAxes.horizontal.angle`. */
57680
+ field: string(),
56638
57681
  outcome: _enum([
56639
57682
  "applied",
56640
57683
  "failed",
56641
57684
  "not-run",
56642
57685
  "refused"
56643
57686
  ]),
56644
- deviceId: number$1().int().nonnegative().optional(),
56645
- cap: string().optional(),
56646
- method: string().optional(),
56647
57687
  /** The cause, for every outcome but `applied`. */
56648
57688
  message: string().optional()
56649
57689
  });
56650
57690
  var FanPresetApplyResultSchema = object({
56651
- /** `applied`: every step ran. `partial`: some ran, one failed. `refused`: nothing ran. */
57691
+ /** `applied`: every field landed. `partial`: some did, one did not. `refused`: nothing ran. */
56652
57692
  status: _enum([
56653
57693
  "applied",
56654
57694
  "partial",
56655
57695
  "refused"
56656
57696
  ]),
56657
- steps: array(FanPresetStepResultSchema)
57697
+ fields: array(FanPresetFieldResultSchema)
56658
57698
  });
56659
57699
  var FanCaptureFieldSchema = _enum([
56660
57700
  "speed",
56661
- "oscillating",
56662
57701
  "direction",
56663
- "preset"
57702
+ "preset",
57703
+ "oscillationAxes"
56664
57704
  ]);
56665
- var FanCaptureOmissionSchema = object({
56666
- /** A fan field (`speed`) or `step <n>` for a sibling step. */
56667
- what: string(),
56668
- /** Why no value was taken: unknown reading (never stored as 0, D393), or a step that cannot be read. */
56669
- reason: string()
56670
- });
56671
- var FanCaptureItemSchema = object({
56672
- group: _enum([
56673
- "fan",
56674
- "oscillation",
56675
- "angles"
56676
- ]),
56677
- /** What the step sets, in the operator's words (a device name, a fan field). */
56678
- label: string()
56679
- });
56680
57705
  var FanPresetCaptureResultSchema = object({
56681
- /** The steps filled from the device's current values; NOT saved. */
56682
- steps: array(ActionStepSchema),
56683
- /** One entry per step, same order: its review group and label. */
56684
- items: array(FanCaptureItemSchema),
56685
- omitted: array(FanCaptureOmissionSchema)
57706
+ /** The fan's CURRENT fan-control state, as a preset; NOT saved. */
57707
+ state: FanPresetStateSchema,
57708
+ omitted: array(object({
57709
+ /** A state path (`speed`, `oscillationAxes.vertical.angle`). */
57710
+ what: string(),
57711
+ /** Why no value was taken: an unknown reading is never stored as 0 (D393). */
57712
+ reason: string()
57713
+ }))
56686
57714
  });
56687
- var deviceInput$2 = object({ deviceId: number$1().int().nonnegative() });
57715
+ var deviceInput$1 = object({ deviceId: number$1().int().nonnegative() });
56688
57716
  DeviceType.Fan;
56689
- method(deviceInput$2, array(FanPresetSchema), { auth: "protected" }), method(deviceInput$2.extend({ presets: array(FanPresetSchema).max(12) }), FanPresetsSetResultSchema, {
57717
+ method(deviceInput$1, array(FanPresetSchema), { auth: "protected" }), method(deviceInput$1.extend({ presets: array(FanPresetSchema).max(12) }), FanPresetsSetResultSchema, {
56690
57718
  kind: "mutation",
56691
57719
  auth: "admin"
56692
- }), method(deviceInput$2.extend({ presetId: string().min(1) }), FanPresetApplyResultSchema, {
57720
+ }), method(deviceInput$1.extend({ presetId: string().min(1) }), FanPresetApplyResultSchema, {
56693
57721
  kind: "mutation",
56694
57722
  auth: "protected"
56695
- }), method(deviceInput$2.extend({
56696
- fields: array(FanCaptureFieldSchema).optional(),
56697
- steps: array(ActionStepSchema).max(20).default([])
56698
- }), FanPresetCaptureResultSchema, { auth: "protected" });
57723
+ }), method(deviceInput$1.extend({ fields: array(FanCaptureFieldSchema).optional() }), FanPresetCaptureResultSchema, { auth: "protected" });
56699
57724
  /**
56700
57725
  * Per-device feature/identity probe slice. Holds the runtime-resolved
56701
57726
  * truth about what a device CAN do — which the kernel uses to:
@@ -56907,8 +57932,7 @@ var groupActiveCapability = {
56907
57932
  DeviceType.Valve,
56908
57933
  DeviceType.Lock,
56909
57934
  DeviceType.MediaPlayer,
56910
- DeviceType.Climate,
56911
- DeviceType.Thermostat
57935
+ DeviceType.Climate
56912
57936
  ],
56913
57937
  methods: {},
56914
57938
  status: {
@@ -56950,7 +57974,6 @@ var groupCommandsCapability = {
56950
57974
  DeviceType.Valve,
56951
57975
  DeviceType.Lock,
56952
57976
  DeviceType.MediaPlayer,
56953
- DeviceType.Thermostat,
56954
57977
  DeviceType.Climate
56955
57978
  ],
56956
57979
  methods: {},
@@ -57006,7 +58029,6 @@ var groupStatusCapability = {
57006
58029
  DeviceType.Valve,
57007
58030
  DeviceType.Lock,
57008
58031
  DeviceType.MediaPlayer,
57009
- DeviceType.Thermostat,
57010
58032
  DeviceType.Climate
57011
58033
  ],
57012
58034
  methods: {},
@@ -57335,7 +58357,7 @@ var heatingValveCapability = {
57335
58357
  scope: "device",
57336
58358
  deviceNative: true,
57337
58359
  mode: "singleton",
57338
- deviceTypes: [DeviceType.Thermostat],
58360
+ deviceTypes: [DeviceType.Climate],
57339
58361
  methods: {},
57340
58362
  status: {
57341
58363
  schema: HeatingValveStatusSchema,
@@ -57365,13 +58387,18 @@ var heatingValveCapability = {
57365
58387
  * retried in a loop - D49); a future one is re-armed.
57366
58388
  *
57367
58389
  * What a device may be run for, and for how long at most, is per device type
57368
- * ({@link TIMED_RUN_LIMITS}): the maximum is a safety bound, not a preference,
58390
+ * and role ({@link timedRunLimitOf}): the maximum is a safety bound, not a preference,
57369
58391
  * so a request above it is REFUSED with a typed reason, never clamped.
57370
58392
  */
57371
58393
  var TimedRunUnitSchema = _enum(["s", "min"]);
57372
58394
  _enum(["switch", "pet-fountain"]);
57373
- DeviceType.Heater, DeviceType.PetFountain;
57374
- var TIMED_RUN_DEVICE_TYPES = [DeviceType.Heater, DeviceType.PetFountain];
58395
+ /**
58396
+ * The device types `timed-run` may be offered on. A `switch` is narrowed by
58397
+ * role ({@link TIMED_RUN_DEVICE_ROLES}): only a plug-in heater runs on a timer.
58398
+ */
58399
+ var TIMED_RUN_DEVICE_TYPES = [DeviceType.Switch, DeviceType.PetFountain];
58400
+ /** The roles that make a device of these types timed-run-able (D1033). */
58401
+ var TIMED_RUN_DEVICE_ROLES = { [DeviceType.Switch]: [DeviceRole.SwitchHeater] };
57375
58402
  /** One duration, as the one-tap run asks for it. */
57376
58403
  var TimedRunQuickRunSchema = object({
57377
58404
  duration: number$1().positive().finite(),
@@ -57421,6 +58448,7 @@ var timedRunCapability = {
57421
58448
  kind: "wrapper",
57422
58449
  defaultActive: true,
57423
58450
  deviceTypes: TIMED_RUN_DEVICE_TYPES,
58451
+ deviceRoles: TIMED_RUN_DEVICE_ROLES,
57424
58452
  deviceConfig: { ui: {
57425
58453
  kind: "derived-form",
57426
58454
  builderId: "timed-run",
@@ -57581,8 +58609,8 @@ var HeroSceneSchema = object({
57581
58609
  /** 0..1: volume, cover position, cycle progress. */
57582
58610
  level: number$1().min(0).max(1).nullable()
57583
58611
  });
57584
- DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Heater, DeviceType.Dryer, DeviceType.Gateway;
57585
- DeviceRole.Siren, DeviceRole.Floodlight, DeviceRole.Spotlight, DeviceRole.PirSensor, DeviceRole.Chime, DeviceRole.Autotrack, DeviceRole.Nightvision, DeviceRole.PrivacyMask, DeviceRole.Doorbell, DeviceRole.CoverBlind, DeviceRole.CoverShutter, DeviceRole.CoverAwning, DeviceRole.CoverCurtain, DeviceRole.CoverGarage, DeviceRole.CoverGate, DeviceRole.CoverDoor, DeviceRole.CoverWindow, DeviceRole.BinaryHelper, DeviceRole.Outlet, DeviceRole.MotionSensor, DeviceRole.ContactSensor, DeviceRole.LeakSensor, DeviceRole.SmokeSensor, DeviceRole.COSensor, DeviceRole.GasSensor, DeviceRole.TamperSensor, DeviceRole.VibrationSensor, DeviceRole.ConnectivitySensor, DeviceRole.SoundSensor, DeviceRole.BinarySensor, DeviceRole.TemperatureSensor, DeviceRole.HumiditySensor, DeviceRole.AmbientLightSensor, DeviceRole.PressureSensor, DeviceRole.WindSensor, DeviceRole.RainSensor, DeviceRole.UvSensor, DeviceRole.SolarRadiationSensor, DeviceRole.SoilMoistureSensor, DeviceRole.PowerSensor, DeviceRole.EnergySensor, DeviceRole.VoltageSensor, DeviceRole.CurrentSensor, DeviceRole.AirQualitySensor, DeviceRole.BatterySensor, DeviceRole.NumericSensor, DeviceRole.EnumSensor, DeviceRole.DateTimeSensor, DeviceRole.GenericSensor, DeviceRole.NumericControl, DeviceRole.SelectControl, DeviceRole.TextControl, DeviceRole.DateTimeControl, DeviceRole.MobilePushNotifier, DeviceRole.MessagingNotifier, DeviceRole.EmailNotifier, DeviceRole.GenericNotifier, DeviceRole.IrTransmitter;
58612
+ DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway;
58613
+ DeviceRole.Siren, DeviceRole.Floodlight, DeviceRole.Spotlight, DeviceRole.PirSensor, DeviceRole.Chime, DeviceRole.Autotrack, DeviceRole.Nightvision, DeviceRole.PrivacyMask, DeviceRole.Doorbell, DeviceRole.CoverBlind, DeviceRole.CoverShutter, DeviceRole.CoverAwning, DeviceRole.CoverCurtain, DeviceRole.CoverGarage, DeviceRole.CoverGate, DeviceRole.CoverDoor, DeviceRole.CoverWindow, DeviceRole.BinaryHelper, DeviceRole.Outlet, DeviceRole.SwitchHeater, DeviceRole.SwitchFan, DeviceRole.ClimateAc, DeviceRole.ClimateThermostat, DeviceRole.ClimateFloor, DeviceRole.ClimateRadiator, DeviceRole.ClimateHeater, DeviceRole.ClimateHeatPumpCircuit, DeviceRole.MotionSensor, DeviceRole.ContactSensor, DeviceRole.LeakSensor, DeviceRole.SmokeSensor, DeviceRole.COSensor, DeviceRole.GasSensor, DeviceRole.TamperSensor, DeviceRole.VibrationSensor, DeviceRole.ConnectivitySensor, DeviceRole.SoundSensor, DeviceRole.BinarySensor, DeviceRole.TemperatureSensor, DeviceRole.HumiditySensor, DeviceRole.AmbientLightSensor, DeviceRole.PressureSensor, DeviceRole.WindSensor, DeviceRole.RainSensor, DeviceRole.UvSensor, DeviceRole.SolarRadiationSensor, DeviceRole.SoilMoistureSensor, DeviceRole.PowerSensor, DeviceRole.EnergySensor, DeviceRole.VoltageSensor, DeviceRole.CurrentSensor, DeviceRole.AirQualitySensor, DeviceRole.BatterySensor, DeviceRole.NumericSensor, DeviceRole.EnumSensor, DeviceRole.DateTimeSensor, DeviceRole.GenericSensor, DeviceRole.NumericControl, DeviceRole.SelectControl, DeviceRole.TextControl, DeviceRole.DateTimeControl, DeviceRole.MobilePushNotifier, DeviceRole.MessagingNotifier, DeviceRole.EmailNotifier, DeviceRole.GenericNotifier, DeviceRole.IrTransmitter;
57586
58614
  /** A string in both languages, as the cap vocabulary writes it (D920): no `t()` in the model. */
57587
58615
  var HeroTextSchema = object({
57588
58616
  en: string(),
@@ -57648,13 +58676,11 @@ var HeroConfirmSchema = _enum([
57648
58676
  "press"
57649
58677
  ]);
57650
58678
  var HeroActionSchema = object({
57651
- /** Unique within the model; the `gated` list and the specs name it. */
57652
58679
  id: string(),
57653
58680
  label: HeroTextSchema,
57654
58681
  icon: IconKeySchema,
57655
58682
  call: HeroCallSchema,
57656
58683
  emphasis: _enum(["primary", "normal"]),
57657
- /** Ask before running it. */
57658
58684
  confirm: HeroConfirmSchema.nullable()
57659
58685
  });
57660
58686
  var HeroControlSchema = discriminatedUnion("kind", [
@@ -57705,6 +58731,8 @@ var HeroPillSchema = object({
57705
58731
  });
57706
58732
  var HeroRowSchema = object({
57707
58733
  id: HeroRowIdSchema,
58734
+ /** `scene`: the row's action and slider controls are drawn on the scene. Absent: an ordinary row. */
58735
+ placement: _enum(["row", "scene"]).optional(),
57708
58736
  /** The row's own name when the kind's label would not tell rows of one kind apart (a circuit's label). */
57709
58737
  label: HeroTextSchema.optional(),
57710
58738
  /** A group header drawn above the row when it differs from the previous row's ("System"). */
@@ -57735,9 +58763,16 @@ var HeroBadgeSchema = object({
57735
58763
  value: number$1().nullable(),
57736
58764
  label: HeroTextSchema
57737
58765
  });
57738
- var HeroRemoteSlotSchema = object({
57739
- /** Saved commands; `null` = not known yet (the button still shows). */
57740
- commands: number$1().int().nullable() });
58766
+ var HeroRemoteSlotSchema = object({
58767
+ /** Saved commands; `null` = not known yet (the button still shows). */
58768
+ commands: number$1().int().nullable(),
58769
+ /**
58770
+ * The control a list row draws as the "open the remote" icon. Recognised by its id
58771
+ * ({@link REMOTE_OPEN_ACTION_ID}); it changes no state, so a client opens the remote
58772
+ * instead of running `call`. Absent on a model from an older server.
58773
+ */
58774
+ open: HeroActionSchema.optional()
58775
+ });
57741
58776
  var HeroConsumablesSlotSchema = object({
57742
58777
  items: array(object({
57743
58778
  key: string(),
@@ -57905,6 +58940,8 @@ var HeroModelSchema = object({
57905
58940
  * the FIRST row that has any (a later row's control is never promoted). When
57906
58941
  * the model lists that part in `gated`, the row has no inline control: the
57907
58942
  * projection never falls through to another control the caller could use.
58943
+ * - a climate or thermostat row's primary is its `climate.mode` control (no power
58944
+ * toggle: "off" is a mode); when the model gates it the rule above applies as before;
57908
58945
  * - a timed run, when the model carries the slot, sits beside that primary:
57909
58946
  * Run while idle, Stop while armed. Gating the slot hides the control.
57910
58947
  * - the status line is the head (what plays, else the state) plus at most
@@ -57912,6 +58949,13 @@ var HeroModelSchema = object({
57912
58949
  * time left on an armed timed run;
57913
58950
  * - the trailing fact is the first of power, battery, consumables.
57914
58951
  *
58952
+ * - `extras` are the further inline controls of the types that carry more than one
58953
+ * ({@link ROW_EXTRAS_BY_TYPE}): climate setpoint, a robot's return to base,
58954
+ * a player's volume and remote. Each is a control the model already has, named by id,
58955
+ * in that order and at most {@link ROW_EXTRAS_MAX}; one the model lacks, or lists in
58956
+ * `gated`, is absent, and the one `primary` already is never repeats. Empty = the
58957
+ * row of an older server: `primary` alone.
58958
+ *
57915
58959
  * `projectDeviceRow` is pure and computes nothing: it selects from a
57916
58960
  * {@link HeroModel}. A row that needed a value the hero lacks would mean the
57917
58961
  * model is missing it, so the fix goes in the model.
@@ -57943,8 +58987,16 @@ var HeroRowModelSchema = object({
57943
58987
  * while armed. `null` when there is no slot, or the caller may not act.
57944
58988
  */
57945
58989
  timedRun: HeroRowTimedRunSchema.nullable(),
58990
+ /**
58991
+ * The further inline controls after `primary`, in order (see the header). The remote
58992
+ * is an `action` with id `remote.open`: the client opens the remote, it does not run
58993
+ * the call. Absent or empty = `primary` alone.
58994
+ */
58995
+ extras: array(HeroControlSchema).max(2).optional(),
57946
58996
  trailing: HeroFactSchema.nullable()
57947
58997
  });
58998
+ DeviceType.Climate;
58999
+ DeviceType.Climate, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer;
57948
59000
  /**
57949
59001
  * hero-layout — what a device's hero says (D1023). Read-only: the FIXED
57950
59002
  * layouts live in `@camstack/types` and nothing here stores or edits one.
@@ -57976,7 +59028,10 @@ method(heroInput, HeroModelSchema.nullable(), {
57976
59028
  }), method(rowModelsInput, array(HeroRowModelSchema.nullable()), {
57977
59029
  auth: "protected",
57978
59030
  role: "viewer"
57979
- }), method(object({ deviceType: string() }), HeroLayoutRowsSchema, {
59031
+ }), method(object({
59032
+ deviceType: string(),
59033
+ role: string().nullable().optional()
59034
+ }), HeroLayoutRowsSchema, {
57980
59035
  auth: "protected",
57981
59036
  role: "viewer"
57982
59037
  });
@@ -58023,6 +59078,11 @@ var humidifierCapability = {
58023
59078
  deviceNative: true,
58024
59079
  mode: "singleton",
58025
59080
  deviceTypes: [DeviceType.Humidifier],
59081
+ power: {
59082
+ kind: "field",
59083
+ field: "on",
59084
+ set: "setOn"
59085
+ },
58026
59086
  methods: {
58027
59087
  setOn: method(object({
58028
59088
  deviceId: number$1().int().nonnegative(),
@@ -58096,7 +59156,7 @@ var humiditySensorCapability = {
58096
59156
  scope: "device",
58097
59157
  deviceNative: true,
58098
59158
  mode: "singleton",
58099
- deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat],
59159
+ deviceTypes: [DeviceType.Sensor, DeviceType.Climate],
58100
59160
  methods: {},
58101
59161
  status: {
58102
59162
  schema: HumiditySensorStatusSchema,
@@ -58390,6 +59450,12 @@ var IntegrationLiteSchema = object({
58390
59450
  var AvailableIntegrationTypeSchema = object({
58391
59451
  addonId: string(),
58392
59452
  name: string(),
59453
+ /**
59454
+ * Product name a new integration is prefilled from (`<label> - Home`).
59455
+ * Absent: the client uses `name`. Set when the catalog title is not the
59456
+ * product (Bosch SHC, MQTT).
59457
+ */
59458
+ providerLabel: string().optional(),
58393
59459
  description: string(),
58394
59460
  iconUrl: string().nullable(),
58395
59461
  color: string(),
@@ -69494,13 +70560,23 @@ var NcRuleTargetSchema = object({
69494
70560
  */
69495
70561
  params: record(string(), unknown()).optional()
69496
70562
  });
70563
+ /** Which picture kind one target receives (#487). */
70564
+ var NcTargetAttachmentSchema = _enum([
70565
+ "none",
70566
+ "image",
70567
+ "clip",
70568
+ "gif"
70569
+ ]);
69497
70570
  var NcTargetOverrideSchema = object({
69498
70571
  template: object({
69499
70572
  title: string().max(500).optional(),
69500
70573
  body: string().max(2e3).optional()
69501
70574
  }).optional(),
69502
70575
  schedule: NcScheduleSchema.optional(),
69503
- priority: number$1().int().min(1).max(5).optional()
70576
+ priority: number$1().int().min(1).max(5).optional(),
70577
+ attachment: NcTargetAttachmentSchema.optional(),
70578
+ /** `false` = no buttons for this target; `true` is the same as absent. */
70579
+ actions: boolean().optional()
69504
70580
  });
69505
70581
  /** Per-target settings keyed by target id (#487). */
69506
70582
  var NcTargetOverridesSchema = record(string(), NcTargetOverrideSchema);
@@ -71268,7 +72344,18 @@ var OauthIntegrationDescriptorSchema = object({
71268
72344
  * The value is baked into the authorization code at `/authorize` and travels
71269
72345
  * on the tokens, so editing this field changes FUTURE links only.
71270
72346
  */
71271
- refreshTokenTtlSec: union([number$1().int().positive(), literal("never")]).optional()
72347
+ refreshTokenTtlSec: union([number$1().int().positive(), literal("never")]).optional(),
72348
+ /**
72349
+ * The integration instances an account can be linked to (planning#415),
72350
+ * beside the default one. `/api/oauth2/authorize?instance=<id>` is accepted
72351
+ * only for an id listed here; with no `instance=` the link is made to the
72352
+ * default instance, which is never listed and always accepted — it is the
72353
+ * address third parties already hold. Omit for a single-instance integration.
72354
+ */
72355
+ instances: array(object({
72356
+ id: string(),
72357
+ displayName: string()
72358
+ })).optional()
71272
72359
  });
71273
72360
  method(_void(), OauthIntegrationDescriptorSchema, { auth: "admin" });
71274
72361
  /**
@@ -71691,9 +72778,13 @@ var petFeederCapability = {
71691
72778
  */
71692
72779
  durability: "session",
71693
72780
  /**
71694
- * History (D850): recorded series.
72781
+ * History (D850): recorded series. `feeding` / `eating` are the meals the
72782
+ * device hero's "Meals" button names — change-only booleans, so a dispense
72783
+ * or a pet at the bowl is one span on the history.
71695
72784
  */
71696
72785
  history: { fields: {
72786
+ feeding: { kind: "boolean" },
72787
+ eating: { kind: "boolean" },
71697
72788
  foodLevel: { kind: "gauge" },
71698
72789
  bowlFood: { kind: "gauge" },
71699
72790
  food1: { kind: "gauge" },
@@ -71754,6 +72845,11 @@ var petFountainCapability = {
71754
72845
  deviceNative: true,
71755
72846
  mode: "singleton",
71756
72847
  deviceTypes: [DeviceType.PetFountain],
72848
+ power: {
72849
+ kind: "field",
72850
+ field: "pumpRunning",
72851
+ set: "setPump"
72852
+ },
71757
72853
  methods: {
71758
72854
  /** Start or stop the pump. */
71759
72855
  setPump: method(object({
@@ -73329,7 +74425,6 @@ var POWER_METER_DEVICE_TYPES = [
73329
74425
  DeviceType.Light,
73330
74426
  DeviceType.Fan,
73331
74427
  DeviceType.Climate,
73332
- DeviceType.Thermostat,
73333
74428
  DeviceType.Humidifier,
73334
74429
  DeviceType.WaterHeater,
73335
74430
  DeviceType.MediaPlayer,
@@ -73338,8 +74433,7 @@ var POWER_METER_DEVICE_TYPES = [
73338
74433
  DeviceType.Dishwasher,
73339
74434
  DeviceType.Vacuum,
73340
74435
  DeviceType.LawnMower,
73341
- DeviceType.PetFeeder,
73342
- DeviceType.Heater
74436
+ DeviceType.PetFeeder
73343
74437
  ];
73344
74438
  /** Everything but a bare `Sensor`: a sensor that takes its reading from another sensor adds nothing. */
73345
74439
  var POWER_METER_CUSTOMIZE_DEVICE_TYPES = POWER_METER_DEVICE_TYPES.filter((type) => type !== DeviceType.Sensor);
@@ -73452,118 +74546,6 @@ var powerMeterCapability = {
73452
74546
  volatileStateFields: ["lastFetchedAt"],
73453
74547
  customize: powerMeterCustomize
73454
74548
  };
73455
- var PowerSourceRefusalReasonSchema = _enum([
73456
- "no-power-source",
73457
- "switch-missing",
73458
- "switch-offline",
73459
- "switch-unknown",
73460
- "restart-in-progress",
73461
- "switch-call-failed"
73462
- ]);
73463
- var PowerSourceActionResultSchema = discriminatedUnion("accepted", [object({ accepted: literal(true) }), object({
73464
- accepted: literal(false),
73465
- reason: PowerSourceRefusalReasonSchema,
73466
- /** One line for the operator, naming the switch. */
73467
- message: string()
73468
- })]);
73469
- var PowerSourceInfoSchema = object({
73470
- /** False when the device has no power source linked; the rest is then empty. */
73471
- linked: boolean(),
73472
- switchDeviceId: number$1().nullable(),
73473
- switchName: string().nullable(),
73474
- /** The switch's on/off state, or null when it is unknown. Never false for unknown (D393). */
73475
- switchOn: boolean().nullable(),
73476
- switchOnline: boolean().nullable(),
73477
- restartInProgress: boolean(),
73478
- offSeconds: number$1().int()
73479
- });
73480
- /** The composed slice: the switch state the link mirrors, plus the stored off time. */
73481
- var PowerSourceStateSchema = object({
73482
- /** Null while the switch has not reported: unknown is never `false` (D393). */
73483
- switchOn: boolean().nullable(),
73484
- offSeconds: number$1().int(),
73485
- lastFetchedAt: number$1()
73486
- });
73487
- var POWER_SOURCE_DEVICE_TYPES = [
73488
- DeviceType.Gateway,
73489
- DeviceType.Hub,
73490
- DeviceType.Camera,
73491
- DeviceType.Sensor,
73492
- DeviceType.MediaPlayer,
73493
- DeviceType.Vacuum,
73494
- DeviceType.Generic,
73495
- DeviceType.Washer,
73496
- DeviceType.Dryer,
73497
- DeviceType.Dishwasher,
73498
- DeviceType.PetFountain,
73499
- DeviceType.Fan,
73500
- DeviceType.Humidifier,
73501
- DeviceType.Climate,
73502
- DeviceType.WaterHeater
73503
- ];
73504
- var powerSourceCustomize = {
73505
- version: 1,
73506
- targets: [{
73507
- id: "switch",
73508
- cardinality: "scalar",
73509
- vocabulary: {
73510
- cap: "power-source",
73511
- stateField: "switchOn"
73512
- },
73513
- deviceTypes: POWER_SOURCE_DEVICE_TYPES,
73514
- apply: "add",
73515
- path: "switchOn",
73516
- sources: [{
73517
- kind: "switch",
73518
- transforms: [{ kind: "identity" }]
73519
- }],
73520
- companions: [{
73521
- kind: "constant",
73522
- path: "offSeconds",
73523
- value: 10,
73524
- when: "add",
73525
- formerValues: [0]
73526
- }, {
73527
- kind: "clock",
73528
- path: "lastFetchedAt",
73529
- when: "always"
73530
- }],
73531
- sourceReport: { status: "assignment-status" }
73532
- }]
73533
- };
73534
- var deviceInput$1 = object({ deviceId: number$1().int().nonnegative() });
73535
- var powerSourceCapability = {
73536
- name: "power-source",
73537
- scope: "device",
73538
- mode: "singleton",
73539
- deviceTypes: POWER_SOURCE_DEVICE_TYPES,
73540
- methods: {
73541
- getStatus: systemMethod(deviceInput$1, PowerSourceInfoSchema, { auth: "admin" }),
73542
- powerOn: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
73543
- kind: "mutation",
73544
- auth: "admin"
73545
- }),
73546
- powerOff: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
73547
- kind: "mutation",
73548
- auth: "admin"
73549
- }),
73550
- forceRestart: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
73551
- kind: "mutation",
73552
- auth: "admin"
73553
- })
73554
- },
73555
- runtimeState: PowerSourceStateSchema,
73556
- customize: powerSourceCustomize,
73557
- /**
73558
- * Runtime-state durability: **session** — mirrored from the linked switch on
73559
- * its next report; a restored value would outlive the reading it names.
73560
- *
73561
- * See `RuntimeStateDurability`. Enforced by
73562
- * `scripts/check-runtime-state-durability.ts`.
73563
- */
73564
- durability: "session",
73565
- history: "none"
73566
- };
73567
74549
  /**
73568
74550
  * Presence cap. Models HA `person.*` and `device_tracker.*` entities
73569
74551
  * on `DeviceType.Presence`. Read-only — no setters: presence is
@@ -77030,9 +78012,7 @@ var RUNNING_CUSTOMIZE_DEVICE_TYPES = [
77030
78012
  DeviceType.Humidifier,
77031
78013
  DeviceType.Fan,
77032
78014
  DeviceType.Climate,
77033
- DeviceType.Thermostat,
77034
- DeviceType.PetFeeder,
77035
- DeviceType.Heater
78015
+ DeviceType.PetFeeder
77036
78016
  ];
77037
78017
  var runningStateCapability = {
77038
78018
  name: "running-state",
@@ -78304,6 +79284,13 @@ var SsoBridgeClaimsSchema = object({
78304
79284
  /** OAuth authorization-code binding — set only on `oauth-code` tokens. */
78305
79285
  redirectUri: string().optional(),
78306
79286
  integrationId: string().optional(),
79287
+ /**
79288
+ * The integration INSTANCE an OAuth link was made for (planning#415). Set on
79289
+ * `oauth-code`, `oauth-access` and `oauth-refresh` tokens of a link made
79290
+ * with `instance=`; absent on a link to the default instance. A cloud relay
79291
+ * reads it off the access token to route; the hub answers from the session.
79292
+ */
79293
+ instanceId: string().optional(),
78307
79294
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
78308
79295
  jti: string().optional(),
78309
79296
  /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
@@ -79120,8 +80107,7 @@ var switchCapability = {
79120
80107
  DeviceType.Switch,
79121
80108
  DeviceType.Siren,
79122
80109
  DeviceType.Light,
79123
- DeviceType.Fan,
79124
- DeviceType.Heater
80110
+ DeviceType.Fan
79125
80111
  ],
79126
80112
  methods: { setState: method(object({
79127
80113
  deviceId: number$1(),
@@ -79815,7 +80801,7 @@ var temperatureSensorCapability = {
79815
80801
  scope: "device",
79816
80802
  deviceNative: true,
79817
80803
  mode: "singleton",
79818
- deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat],
80804
+ deviceTypes: [DeviceType.Sensor, DeviceType.Climate],
79819
80805
  methods: {},
79820
80806
  status: {
79821
80807
  schema: TemperatureSensorStatusSchema,
@@ -80683,6 +81669,8 @@ var OauthSessionSummarySchema = object({
80683
81669
  userId: string(),
80684
81670
  username: string(),
80685
81671
  integrationId: string(),
81672
+ /** The instance the link was made for; `null` = the default instance. */
81673
+ instanceId: string().nullable().optional(),
80686
81674
  scopes: array(TokenScopeSchema),
80687
81675
  createdAt: number$1(),
80688
81676
  lastUsedAt: number$1(),
@@ -80782,6 +81770,9 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(_void(), ob
80782
81770
  access: "view"
80783
81771
  }), method(object({
80784
81772
  integrationId: string(),
81773
+ /** The integration instance the link is for. Omit for the default
81774
+ * instance — never pass the addon id to mean it. */
81775
+ instanceId: string().optional(),
80785
81776
  userId: string(),
80786
81777
  username: string(),
80787
81778
  scopes: array(TokenScopeSchema),
@@ -80820,7 +81811,13 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(_void(), ob
80820
81811
  }), method(object({ token: string() }), object({
80821
81812
  userId: string(),
80822
81813
  username: string(),
80823
- scopes: array(TokenScopeSchema)
81814
+ scopes: array(TokenScopeSchema),
81815
+ /** The instance the link was made for, read from the SESSION.
81816
+ * Absent = the default instance. `oauthLinkServesInstance` is the
81817
+ * helper a per-instance route calls to compare it with the instance
81818
+ * of its path; no route declares `perInstance` yet (the Alexa
81819
+ * per-instance route, T3, is the first consumer). */
81820
+ instanceId: string().optional()
80824
81821
  }).nullable(), { access: "view" }), method(object({ userId: string() }), array(ExternalIdentitySchema), { auth: "admin" }), method(ExternalIdentityKeySchema, object({
80825
81822
  userId: string(),
80826
81823
  issuer: string()
@@ -81462,6 +82459,13 @@ var waterHeaterCapability = {
81462
82459
  deviceNative: true,
81463
82460
  mode: "singleton",
81464
82461
  deviceTypes: [DeviceType.WaterHeater],
82462
+ power: {
82463
+ kind: "mode",
82464
+ field: "operationMode",
82465
+ offValue: "off",
82466
+ set: "setOperationMode",
82467
+ lastField: "lastActiveMode"
82468
+ },
81465
82469
  methods: {
81466
82470
  setTargetTemp: method(object({
81467
82471
  deviceId: number$1().int().nonnegative(),
@@ -83120,7 +84124,6 @@ var DEVICE_LOCAL_STATE_CAPS = {
83120
84124
  control: controlCapability,
83121
84125
  cover: coverCapability,
83122
84126
  dayNight: dayNightCapability,
83123
- deviceDiscovery: deviceDiscoveryCapability,
83124
84127
  deviceStatus: deviceStatusCapability,
83125
84128
  dishwasher: dishwasherCapability,
83126
84129
  doNotDisturb: doNotDisturbCapability,
@@ -84215,15 +85218,16 @@ var BaseDeviceProvider = class extends BaseAddon {
84215
85218
  });
84216
85219
  const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
84217
85220
  if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
84218
- const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
84219
- const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
85221
+ const Class = this.deviceClassForRow({
85222
+ type: meta.type,
85223
+ stableId
85224
+ });
84220
85225
  if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
84221
85226
  await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
84222
85227
  const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
84223
85228
  for (const row of rows) {
84224
85229
  if (row.parentDeviceId !== id) continue;
84225
- const childType = Object.values(DeviceType).find((t) => t === row.type);
84226
- const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
85230
+ const ChildClass = this.deviceClassForRow(row);
84227
85231
  if (!ChildClass) continue;
84228
85232
  try {
84229
85233
  await devices.create(row.stableId, ChildClass, {}, id);
@@ -84250,6 +85254,16 @@ var BaseDeviceProvider = class extends BaseAddon {
84250
85254
  return { deviceId: id };
84251
85255
  }
84252
85256
  /**
85257
+ * The class a PERSISTED row is rebuilt with. Default: by type, from
85258
+ * `deviceClasses`. A provider whose distinct devices share one type (D1033:
85259
+ * a radiator valve and a room climate are both `climate`) overrides this
85260
+ * and tells them apart by the row's stable id or config.
85261
+ */
85262
+ deviceClassForRow(row) {
85263
+ const type = Object.values(DeviceType).find((t) => t === row.type);
85264
+ return type !== void 0 ? this.deviceClasses[type] : void 0;
85265
+ }
85266
+ /**
84253
85267
  * Restore devices from persisted state. Two-pass:
84254
85268
  *
84255
85269
  * 1. **Top-level pass** — invokes `kernel.devices.create()` for every
@@ -84310,7 +85324,7 @@ var BaseDeviceProvider = class extends BaseAddon {
84310
85324
  restored.add(saved.id);
84311
85325
  return;
84312
85326
  }
84313
- const Class = this.deviceClasses[saved.type];
85327
+ const Class = this.deviceClassForRow(saved);
84314
85328
  if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
84315
85329
  if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
84316
85330
  await this.healSavedConfig(saved, savedDevices);
@@ -84327,7 +85341,7 @@ var BaseDeviceProvider = class extends BaseAddon {
84327
85341
  };
84328
85342
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
84329
85343
  const restoreOne = async (saved) => {
84330
- if (!this.deviceClasses[saved.type]) {
85344
+ if (!this.deviceClassForRow(saved)) {
84331
85345
  this.ctx.logger.warn("No device class registered for restored type — skipping", {
84332
85346
  tags: {
84333
85347
  deviceId: saved.id,
@@ -84366,7 +85380,7 @@ var BaseDeviceProvider = class extends BaseAddon {
84366
85380
  const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
84367
85381
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
84368
85382
  for (const saved of childRows) {
84369
- if (!this.deviceClasses[saved.type]) continue;
85383
+ if (!this.deviceClassForRow(saved)) continue;
84370
85384
  if (saved.parentDeviceId === null) continue;
84371
85385
  if (restored.has(saved.parentDeviceId)) {
84372
85386
  try {
@@ -84420,7 +85434,9 @@ var BaseDeviceProvider = class extends BaseAddon {
84420
85434
  }
84421
85435
  };
84422
85436
  DeviceType.HeatPump;
84423
- DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Heater, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway;
85437
+ DeviceType.Climate;
85438
+ DeviceRole.ClimateThermostat, DeviceRole.ClimateAc, DeviceRole.ClimateRadiator, DeviceRole.ClimateFloor, DeviceRole.ClimateHeater, DeviceRole.ClimateHeatPumpCircuit;
85439
+ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway;
84424
85440
  new Set(Object.values(DeviceType));
84425
85441
  /** Parses the raw slice with the cap's own schema; `undefined` when it does not parse. */
84426
85442
  function contribution(cap, schema, build) {
@@ -86426,7 +87442,7 @@ var HEAT_PUMP_FAMILY_CONTRIBUTIONS = { "heat-pump-status": contribution("heat-pu
86426
87442
  ...Object.values(CLIMATE_FAMILY_CONTRIBUTIONS),
86427
87443
  ...Object.values(HEAT_PUMP_FAMILY_CONTRIBUTIONS)
86428
87444
  ];
86429
- DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Heater, DeviceType.Dryer, DeviceType.Gateway;
87445
+ DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway;
86430
87446
  /**
86431
87447
  * Who looks like what (D1023). One look per {@link DeviceType}, refined by a
86432
87448
  * {@link DeviceRole} where the role changes how the device reads (a leak
@@ -87070,9 +88086,9 @@ lookOf({
87070
88086
  idle: INFO,
87071
88087
  standby: MUTED
87072
88088
  });
87073
- DeviceType.Climate, DeviceType.Thermostat;
87074
- DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Heater, DeviceType.Dryer, DeviceType.Gateway;
87075
- DeviceRole.Siren, DeviceRole.Floodlight, DeviceRole.Spotlight, DeviceRole.PirSensor, DeviceRole.Chime, DeviceRole.Autotrack, DeviceRole.Nightvision, DeviceRole.PrivacyMask, DeviceRole.Doorbell, DeviceRole.CoverBlind, DeviceRole.CoverShutter, DeviceRole.CoverAwning, DeviceRole.CoverCurtain, DeviceRole.CoverGarage, DeviceRole.CoverGate, DeviceRole.CoverDoor, DeviceRole.CoverWindow, DeviceRole.BinaryHelper, DeviceRole.Outlet, DeviceRole.MotionSensor, DeviceRole.ContactSensor, DeviceRole.LeakSensor, DeviceRole.SmokeSensor, DeviceRole.COSensor, DeviceRole.GasSensor, DeviceRole.TamperSensor, DeviceRole.VibrationSensor, DeviceRole.ConnectivitySensor, DeviceRole.SoundSensor, DeviceRole.BinarySensor, DeviceRole.TemperatureSensor, DeviceRole.HumiditySensor, DeviceRole.AmbientLightSensor, DeviceRole.PressureSensor, DeviceRole.WindSensor, DeviceRole.RainSensor, DeviceRole.UvSensor, DeviceRole.SolarRadiationSensor, DeviceRole.SoilMoistureSensor, DeviceRole.PowerSensor, DeviceRole.EnergySensor, DeviceRole.VoltageSensor, DeviceRole.CurrentSensor, DeviceRole.AirQualitySensor, DeviceRole.BatterySensor, DeviceRole.NumericSensor, DeviceRole.EnumSensor, DeviceRole.DateTimeSensor, DeviceRole.GenericSensor, DeviceRole.NumericControl, DeviceRole.SelectControl, DeviceRole.TextControl, DeviceRole.DateTimeControl, DeviceRole.MobilePushNotifier, DeviceRole.MessagingNotifier, DeviceRole.EmailNotifier, DeviceRole.GenericNotifier, DeviceRole.IrTransmitter;
88089
+ DeviceType.Climate;
88090
+ DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway;
88091
+ DeviceRole.Siren, DeviceRole.Floodlight, DeviceRole.Spotlight, DeviceRole.PirSensor, DeviceRole.Chime, DeviceRole.Autotrack, DeviceRole.Nightvision, DeviceRole.PrivacyMask, DeviceRole.Doorbell, DeviceRole.CoverBlind, DeviceRole.CoverShutter, DeviceRole.CoverAwning, DeviceRole.CoverCurtain, DeviceRole.CoverGarage, DeviceRole.CoverGate, DeviceRole.CoverDoor, DeviceRole.CoverWindow, DeviceRole.BinaryHelper, DeviceRole.Outlet, DeviceRole.SwitchHeater, DeviceRole.SwitchFan, DeviceRole.ClimateAc, DeviceRole.ClimateThermostat, DeviceRole.ClimateFloor, DeviceRole.ClimateRadiator, DeviceRole.ClimateHeater, DeviceRole.ClimateHeatPumpCircuit, DeviceRole.MotionSensor, DeviceRole.ContactSensor, DeviceRole.LeakSensor, DeviceRole.SmokeSensor, DeviceRole.COSensor, DeviceRole.GasSensor, DeviceRole.TamperSensor, DeviceRole.VibrationSensor, DeviceRole.ConnectivitySensor, DeviceRole.SoundSensor, DeviceRole.BinarySensor, DeviceRole.TemperatureSensor, DeviceRole.HumiditySensor, DeviceRole.AmbientLightSensor, DeviceRole.PressureSensor, DeviceRole.WindSensor, DeviceRole.RainSensor, DeviceRole.UvSensor, DeviceRole.SolarRadiationSensor, DeviceRole.SoilMoistureSensor, DeviceRole.PowerSensor, DeviceRole.EnergySensor, DeviceRole.VoltageSensor, DeviceRole.CurrentSensor, DeviceRole.AirQualitySensor, DeviceRole.BatterySensor, DeviceRole.NumericSensor, DeviceRole.EnumSensor, DeviceRole.DateTimeSensor, DeviceRole.GenericSensor, DeviceRole.NumericControl, DeviceRole.SelectControl, DeviceRole.TextControl, DeviceRole.DateTimeControl, DeviceRole.MobilePushNotifier, DeviceRole.MessagingNotifier, DeviceRole.EmailNotifier, DeviceRole.GenericNotifier, DeviceRole.IrTransmitter;
87076
88092
  Object.freeze({
87077
88093
  "accessories.getStatus": {
87078
88094
  capName: "accessories",
@@ -88299,6 +89315,27 @@ Object.freeze({
88299
89315
  access: "create",
88300
89316
  role: "viewer"
88301
89317
  },
89318
+ "clusterUpdate.getClusterUpdateJob": {
89319
+ capName: "cluster-update",
89320
+ capScope: "system",
89321
+ addonId: null,
89322
+ access: "view",
89323
+ role: "admin"
89324
+ },
89325
+ "clusterUpdate.previewClusterUpdate": {
89326
+ capName: "cluster-update",
89327
+ capScope: "system",
89328
+ addonId: null,
89329
+ access: "view",
89330
+ role: "admin"
89331
+ },
89332
+ "clusterUpdate.startClusterUpdate": {
89333
+ capName: "cluster-update",
89334
+ capScope: "system",
89335
+ addonId: null,
89336
+ access: "create",
89337
+ role: "admin"
89338
+ },
88302
89339
  "color.getStatus": {
88303
89340
  capName: "color",
88304
89341
  capScope: "device",
@@ -88901,41 +89938,6 @@ Object.freeze({
88901
89938
  access: "create",
88902
89939
  role: "admin"
88903
89940
  },
88904
- "deviceDiscovery.adoptDevice": {
88905
- capName: "device-discovery",
88906
- capScope: "device",
88907
- addonId: null,
88908
- access: "create",
88909
- role: "admin"
88910
- },
88911
- "deviceDiscovery.getStatus": {
88912
- capName: "device-discovery",
88913
- capScope: "device",
88914
- addonId: null,
88915
- access: "view",
88916
- role: "viewer"
88917
- },
88918
- "deviceDiscovery.listDiscovered": {
88919
- capName: "device-discovery",
88920
- capScope: "device",
88921
- addonId: null,
88922
- access: "view",
88923
- role: "viewer"
88924
- },
88925
- "deviceDiscovery.refreshDiscovery": {
88926
- capName: "device-discovery",
88927
- capScope: "device",
88928
- addonId: null,
88929
- access: "create",
88930
- role: "admin"
88931
- },
88932
- "deviceDiscovery.releaseDevice": {
88933
- capName: "device-discovery",
88934
- capScope: "device",
88935
- addonId: null,
88936
- access: "create",
88937
- role: "admin"
88938
- },
88939
89941
  "deviceExport.applyDeviceSettingsPatch": {
88940
89942
  capName: "device-export",
88941
89943
  capScope: "system",
@@ -89671,6 +90673,13 @@ Object.freeze({
89671
90673
  access: "create",
89672
90674
  role: "admin"
89673
90675
  },
90676
+ "deviceManager.search": {
90677
+ capName: "device-manager",
90678
+ capScope: "system",
90679
+ addonId: null,
90680
+ access: "view",
90681
+ role: "viewer"
90682
+ },
89674
90683
  "deviceManager.setAlias": {
89675
90684
  capName: "device-manager",
89676
90685
  capScope: "system",
@@ -89762,6 +90771,13 @@ Object.freeze({
89762
90771
  access: "create",
89763
90772
  role: "admin"
89764
90773
  },
90774
+ "deviceManager.setPrimaryChildPreference": {
90775
+ capName: "device-manager",
90776
+ capScope: "system",
90777
+ addonId: null,
90778
+ access: "create",
90779
+ role: "admin"
90780
+ },
89765
90781
  "deviceManager.setRole": {
89766
90782
  capName: "device-manager",
89767
90783
  capScope: "system",
@@ -90469,6 +91485,13 @@ Object.freeze({
90469
91485
  access: "create",
90470
91486
  role: "viewer"
90471
91487
  },
91488
+ "fanControl.setOscillationAxis": {
91489
+ capName: "fan-control",
91490
+ capScope: "device",
91491
+ addonId: null,
91492
+ access: "create",
91493
+ role: "viewer"
91494
+ },
90472
91495
  "fanControl.setPercentage": {
90473
91496
  capName: "fan-control",
90474
91497
  capScope: "device",
@@ -94401,28 +95424,28 @@ Object.freeze({
94401
95424
  capScope: "device",
94402
95425
  addonId: null,
94403
95426
  access: "create",
94404
- role: "admin"
95427
+ role: "viewer"
94405
95428
  },
94406
95429
  "powerSource.getStatus": {
94407
95430
  capName: "power-source",
94408
95431
  capScope: "device",
94409
95432
  addonId: null,
94410
95433
  access: "view",
94411
- role: "admin"
95434
+ role: "viewer"
94412
95435
  },
94413
95436
  "powerSource.powerOff": {
94414
95437
  capName: "power-source",
94415
95438
  capScope: "device",
94416
95439
  addonId: null,
94417
95440
  access: "create",
94418
- role: "admin"
95441
+ role: "viewer"
94419
95442
  },
94420
95443
  "powerSource.powerOn": {
94421
95444
  capName: "power-source",
94422
95445
  capScope: "device",
94423
95446
  addonId: null,
94424
95447
  access: "create",
94425
- role: "admin"
95448
+ role: "viewer"
94426
95449
  },
94427
95450
  "presence.getStatus": {
94428
95451
  capName: "presence",
@@ -97490,7 +98513,7 @@ function projectAction(cap, action) {
97490
98513
  };
97491
98514
  }
97492
98515
  Object.entries(CAP_VOCABULARY_BY_NAME).flatMap(([cap, entry]) => entry.scope === "device" ? (entry.actions ?? []).map((a) => projectAction(cap, a)) : []);
97493
- DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Heater, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway;
98516
+ DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.HeatPump, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Gateway;
97494
98517
  DeviceFeature.BatteryOperated;
97495
98518
  function bound(cap, methods = [], authority = "reported") {
97496
98519
  return {
@@ -97979,35 +99002,6 @@ Object.freeze({
97979
99002
  form: "single",
97980
99003
  optional: false
97981
99004
  }],
97982
- "deviceDiscovery.adoptDevice": [{
97983
- name: "deviceId",
97984
- form: "single",
97985
- optional: false
97986
- }],
97987
- "deviceDiscovery.getStatus": [{
97988
- name: "deviceId",
97989
- form: "single",
97990
- optional: false
97991
- }],
97992
- "deviceDiscovery.listDiscovered": [{
97993
- name: "deviceId",
97994
- form: "single",
97995
- optional: false
97996
- }],
97997
- "deviceDiscovery.refreshDiscovery": [{
97998
- name: "deviceId",
97999
- form: "single",
98000
- optional: false
98001
- }],
98002
- "deviceDiscovery.releaseDevice": [{
98003
- name: "childDeviceId",
98004
- form: "single",
98005
- optional: false
98006
- }, {
98007
- name: "deviceId",
98008
- form: "single",
98009
- optional: false
98010
- }],
98011
99005
  "deviceExport.applyDeviceSettingsPatch": [{
98012
99006
  name: "deviceId",
98013
99007
  form: "single",
@@ -98286,6 +99280,11 @@ Object.freeze({
98286
99280
  form: "single",
98287
99281
  optional: false
98288
99282
  }],
99283
+ "deviceManager.setPrimaryChildPreference": [{
99284
+ name: "deviceId",
99285
+ form: "single",
99286
+ optional: false
99287
+ }],
98289
99288
  "deviceManager.setRole": [{
98290
99289
  name: "deviceId",
98291
99290
  form: "single",
@@ -98566,6 +99565,11 @@ Object.freeze({
98566
99565
  form: "single",
98567
99566
  optional: false
98568
99567
  }],
99568
+ "fanControl.setOscillationAxis": [{
99569
+ name: "deviceId",
99570
+ form: "single",
99571
+ optional: false
99572
+ }],
98569
99573
  "fanControl.setPercentage": [{
98570
99574
  name: "deviceId",
98571
99575
  form: "single",
@@ -101754,7 +102758,9 @@ var TimelapseRuleInputSchema = object({
101754
102758
  /** Classes the notification counts — see {@link ReportClassesField}. */
101755
102759
  reportClasses: ReportClassesField.optional(),
101756
102760
  /** Canonical notification priority ordinal (1..5); per-target overridable. */
101757
- priority: PriorityField.default(3)
102761
+ priority: PriorityField.default(3),
102762
+ /** Per-target settings keyed by target id (D1032) — absent = every target follows the rule. */
102763
+ targetOverrides: NcTargetOverridesSchema.optional()
101758
102764
  });
101759
102765
  object({
101760
102766
  name: NameField.optional(),
@@ -101770,7 +102776,9 @@ object({
101770
102776
  previewText: PreviewTextField.optional(),
101771
102777
  previewMode: PreviewModeField.optional(),
101772
102778
  reportClasses: ReportClassesField.optional(),
101773
- priority: PriorityField.optional()
102779
+ priority: PriorityField.optional(),
102780
+ /** Per-target settings keyed by target id (D1032) — absent = every target follows the rule. */
102781
+ targetOverrides: NcTargetOverridesSchema.optional()
101774
102782
  });
101775
102783
  TimelapseRuleInputSchema.extend({
101776
102784
  id: string(),
@@ -102036,6 +103044,27 @@ function ingestReading(input) {
102036
103044
  value: converted
102037
103045
  };
102038
103046
  }
103047
+ function collapse(value) {
103048
+ return value.trim().replace(/\s+/g, " ");
103049
+ }
103050
+ /** "KLF-200" → "KLF200". A hyphen between words ("Home Hub") stays. */
103051
+ function compactModelToken(value) {
103052
+ return value.replace(/([A-Za-z])-(\d)/g, "$1$2");
103053
+ }
103054
+ /**
103055
+ * A Gateway device: "<Brand> <Model>". An unknown model is the brand alone
103056
+ * (the caller passes the brand-level fallback it already has, e.g. "Velux KLF-200").
103057
+ */
103058
+ function gatewayDeviceName(brand, model) {
103059
+ const brandPart = compactModelToken(collapse(brand));
103060
+ const raw = collapse(model ?? "");
103061
+ if (raw.length === 0) return brandPart;
103062
+ const modelPart = compactModelToken(raw);
103063
+ const prefix = `${brandPart.toLowerCase()} `;
103064
+ if (modelPart.toLowerCase() === brandPart.toLowerCase()) return brandPart;
103065
+ if (modelPart.toLowerCase().startsWith(prefix)) return modelPart;
103066
+ return `${brandPart} ${modelPart}`;
103067
+ }
102039
103068
  /**
102040
103069
  * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
102041
103070
  * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
@@ -102136,13 +103165,15 @@ object({
102136
103165
  * settings of the integration itself, not of a device. Every equipment the box reports is
102137
103166
  * adopted as a Container from it.
102138
103167
  *
102139
- * Two sources behind `source`:
103168
+ * Sources behind `source`:
103169
+ * - `blazor`: the box's own web-page channel (Blazor Server, firmware 8.51 and later),
103170
+ * read by an embedded Python bridge. No token, nothing written to the box.
102140
103171
  * - `influx`: the box's InfluxDB 2.x (port 8086, org `enpal`, bucket `solar`).
102141
103172
  * Needs the access token Enpal support issues; it is a keychain secret.
102142
103173
  * - `page`: the box's local device page (`/deviceMessages`). No token; fewer
102143
103174
  * boxes and firmware versions still serve it.
102144
- * - `auto` (default): InfluxDB when a token is set, the page otherwise or when
102145
- * InfluxDB cannot be read.
103175
+ * - `auto` (default): `blazor`, then InfluxDB when a token is set, then the page; the first
103176
+ * one that answers wins and the snapshot records why a preferred one was skipped.
102146
103177
  */
102147
103178
  /** Strip a pasted scheme and path: `http://192.0.2.10/deviceMessages` -> `192.0.2.10`. */
102148
103179
  function cleanHost(value) {
@@ -102160,6 +103191,7 @@ var FLUX_NAME = /^[A-Za-z0-9_.-]+$/;
102160
103191
  var ENPAL_SYSTEM = "enpal";
102161
103192
  var ENPAL_SOURCES = [
102162
103193
  "auto",
103194
+ "blazor",
102163
103195
  "influx",
102164
103196
  "page"
102165
103197
  ];
@@ -102220,7 +103252,7 @@ function buildConnectionFormSchema() {
102220
103252
  id: "source",
102221
103253
  title: "Data source",
102222
103254
  titleKey: "provider-enpal:connection.s_source.title",
102223
- description: "InfluxDB needs the access token Enpal support issues (ask in the Enpal app chat). Without it the box device page is used.",
103255
+ description: "The box's own web-page channel needs no credential and is used first. InfluxDB is used only with the access token Enpal support issues; the device page is the last resort.",
102224
103256
  descriptionKey: "provider-enpal:connection.s_source.description",
102225
103257
  columns: 2,
102226
103258
  fields: [
@@ -102233,9 +103265,14 @@ function buildConnectionFormSchema() {
102233
103265
  options: [
102234
103266
  {
102235
103267
  value: "auto",
102236
- label: "Automatic (InfluxDB, then device page)",
103268
+ label: "Automatic (box web channel, InfluxDB, then device page)",
102237
103269
  labelKey: "provider-enpal:connection.s_source.source.o_auto.label"
102238
103270
  },
103271
+ {
103272
+ value: "blazor",
103273
+ label: "Box web channel only (firmware 8.51 and later)",
103274
+ labelKey: "provider-enpal:connection.s_source.source.o_blazor.label"
103275
+ },
102239
103276
  {
102240
103277
  value: "influx",
102241
103278
  label: "InfluxDB only",
@@ -102351,6 +103388,22 @@ var point = (quantity, ...sources) => ({
102351
103388
  quantity,
102352
103389
  sources: sources.map((s) => typeof s === "string" ? { key: s } : s)
102353
103390
  });
103391
+ var numeric = (id, name, quantity, key, role = DeviceRole.NumericSensor) => ({
103392
+ kind: "numeric",
103393
+ id,
103394
+ name,
103395
+ role,
103396
+ quantity,
103397
+ value: point(quantity, key)
103398
+ });
103399
+ var text = (id, name, key, timestamp = false) => ({
103400
+ kind: "text",
103401
+ id,
103402
+ name,
103403
+ role: timestamp ? DeviceRole.DateTimeSensor : DeviceRole.EnumSensor,
103404
+ key,
103405
+ ...timestamp ? { timestamp: true } : {}
103406
+ });
102354
103407
  /**
102355
103408
  * What an Enpal box can show, by the dotted keys its InfluxDB and device page use (the
102356
103409
  * names follow the Home Assistant component's key table). A device is offered only when the
@@ -102433,7 +103486,7 @@ var ENPAL_SPECS = [
102433
103486
  id: "house",
102434
103487
  name: "Consumption",
102435
103488
  role: DeviceRole.PowerSensor,
102436
- power: point("power", "Power.Consumption.Total", "Power.Consumption.AC"),
103489
+ power: point("power", "Power.Consumption.Total"),
102437
103490
  kwh: point("energy", "Energy.Consumption.Total.Lifetime")
102438
103491
  },
102439
103492
  {
@@ -102483,7 +103536,87 @@ var ENPAL_SPECS = [
102483
103536
  role: DeviceRole.NumericSensor,
102484
103537
  quantity: "percentage",
102485
103538
  value: point("percentage", "Battery.SOH")
102486
- }
103539
+ },
103540
+ numeric("pv-string-1-power", "String 1 power", "power", "Power.DC.String.1", DeviceRole.PowerSensor),
103541
+ numeric("pv-string-2-power", "String 2 power", "power", "Power.DC.String.2", DeviceRole.PowerSensor),
103542
+ numeric("pv-energy-day", "Production today", "energy", "Energy.Production.Total.Day", DeviceRole.EnergySensor),
103543
+ numeric("pv-string-1-voltage", "String 1 voltage", "voltage", "Voltage.String.1", DeviceRole.VoltageSensor),
103544
+ numeric("pv-string-2-voltage", "String 2 voltage", "voltage", "Voltage.String.2", DeviceRole.VoltageSensor),
103545
+ numeric("pv-string-1-current", "String 1 current", "current", "Current.String.1", DeviceRole.CurrentSensor),
103546
+ numeric("pv-string-2-current", "String 2 current", "current", "Current.String.2", DeviceRole.CurrentSensor),
103547
+ numeric("battery-stored-energy", "Stored energy", "energy", "Energy.Battery.Charge.Load", DeviceRole.EnergySensor),
103548
+ numeric("battery-charge-day", "Charged today", "energy", "Energy.Battery.Charge.Day", DeviceRole.EnergySensor),
103549
+ numeric("battery-discharge-day", "Discharged today", "energy", "Energy.Battery.Discharge.Day", DeviceRole.EnergySensor),
103550
+ numeric("battery-capacity", "Capacity", "energy", "Energy.Battery.Capacity", DeviceRole.EnergySensor),
103551
+ numeric("battery-power-rating", "Maximum power rating", "power", "Battery.Power.Rating.Maximum", DeviceRole.PowerSensor),
103552
+ numeric("battery-level-max", "Maximum charge level", "percentage", "Battery.ChargeLevel.Max"),
103553
+ numeric("battery-level-min", "Minimum charge level", "percentage", "Battery.ChargeLevel.Min"),
103554
+ numeric("battery-power-set", "Power setpoint", "power", "Power.Battery.Charge.Discharge.Set", DeviceRole.PowerSensor),
103555
+ numeric("battery-charge-power-max", "Maximum charge power", "power", "Power.Battery.Charge.Max", DeviceRole.PowerSensor),
103556
+ numeric("battery-discharge-power-max", "Maximum discharge power", "power", "Power.Battery.Discharge.Max", DeviceRole.PowerSensor),
103557
+ numeric("battery-charge-current-max", "Maximum charge current", "current", "Current.Battery.Charge.Max", DeviceRole.CurrentSensor),
103558
+ numeric("battery-discharge-current-max", "Maximum discharge current", "current", "Current.Battery.Discharge.Max", DeviceRole.CurrentSensor),
103559
+ numeric("grid-phase-a", "Phase A power", "power", "Power.AC.Phase.A", DeviceRole.PowerSensor),
103560
+ numeric("grid-phase-b", "Phase B power", "power", "Power.AC.Phase.B", DeviceRole.PowerSensor),
103561
+ numeric("grid-phase-c", "Phase C power", "power", "Power.AC.Phase.C", DeviceRole.PowerSensor),
103562
+ numeric("grid-import-day", "Imported today", "energy", "Energy.Grid.Import.Day", DeviceRole.EnergySensor),
103563
+ numeric("grid-export-day", "Exported today", "energy", "Energy.Grid.Export.Day", DeviceRole.EnergySensor),
103564
+ numeric("grid-voltage-a", "Phase A voltage", "voltage", "Voltage.Phase.A", DeviceRole.VoltageSensor),
103565
+ numeric("grid-voltage-b", "Phase B voltage", "voltage", "Voltage.Phase.B", DeviceRole.VoltageSensor),
103566
+ numeric("grid-voltage-c", "Phase C voltage", "voltage", "Voltage.Phase.C", DeviceRole.VoltageSensor),
103567
+ {
103568
+ kind: "numeric",
103569
+ id: "grid-frequency",
103570
+ name: "Frequency",
103571
+ role: DeviceRole.NumericSensor,
103572
+ quantity: "index",
103573
+ value: {
103574
+ ...point("index", "Frequency.Grid"),
103575
+ ignoreUnit: true
103576
+ }
103577
+ },
103578
+ numeric("grid-feed-max", "Maximum feed-in power", "power", "Power.Grid.Maximum.Feed", DeviceRole.PowerSensor),
103579
+ numeric("house-energy-day", "Consumption today", "energy", "Energy.Consumption.Total.Day", DeviceRole.EnergySensor),
103580
+ text("inverter-state", "System state", "Inverter.System.State"),
103581
+ text("inverter-ems-mode", "EMS mode", "Command.EMS.Mode"),
103582
+ numeric("inverter-ac-power", "AC power", "power", "Power.Inverter.AC.Export", DeviceRole.PowerSensor),
103583
+ text("inverter-start-stop", "Start/stop command", "Command.Start.Stop"),
103584
+ text("inverter-forcible-mode", "Forcible charge/discharge mode", "Mode.Forcible.Charge.Discharge"),
103585
+ numeric("inverter-power-rating", "Maximum power rating", "power", "Inverter.Power.Rating.Maximum", DeviceRole.PowerSensor),
103586
+ text("inverter-phase-type", "Phase type", "Type.Phase.Inverter"),
103587
+ text("inverter-modbus-rtu", "Modbus RTU status", "Inverter.Modbus.Health.Rtu.Status"),
103588
+ text("inverter-modbus-tcp", "Modbus TCP status", "Inverter.Modbus.Health.Tcp.Status"),
103589
+ text("heatpump-hot-water-mode", "Hot water mode", "Heatpump.DomesticHotWater.1.Mode"),
103590
+ text("heatpump-heating-mode", "Heating circuit mode", "Heatpump.HeatingCircuit.1.Mode"),
103591
+ text("heatpump-generator-state", "Generator state", "Heatpump.Generator.State.Running"),
103592
+ text("heatpump-heater-state", "Electric heater state", "Heatpump.ElectricHeater.State.Running"),
103593
+ text("heatpump-energy-management", "Energy management state", "Heatpump.EnergyManagement.State"),
103594
+ text("heatpump-power-desired", "Power requested", "Heatpump.Power.Desired.Enabled"),
103595
+ text("heatpump-pv-optimization", "PV optimisation", "Heatpump.Settings.PvOptimizationStatus"),
103596
+ numeric("heatpump-power-min", "Generator minimum power", "power", "Heatpump.Generator.Power.Min", DeviceRole.PowerSensor),
103597
+ numeric("heatpump-power-max", "Generator maximum power", "power", "Heatpump.Generator.Power.Max", DeviceRole.PowerSensor),
103598
+ text("heatpump-connection", "Local connection", "Heatpump.Local.ConnectionState"),
103599
+ text("box-main-state", "Main state", "IoT.MainState"),
103600
+ numeric("box-cpu-load", "CPU load", "percentage", "Cpu.Load"),
103601
+ numeric("box-memory-usage", "Memory usage", "percentage", "Memory.Usage"),
103602
+ text("box-hardware-check", "Hardware check", "HW.Cronny.Result"),
103603
+ text("eebus-connection", "Connection state", "Eebus.ConnectionState"),
103604
+ text("eebus-discovery", "Discovery state", "Eebus.DiscoveryState"),
103605
+ text("eebus-cert-expiry", "Certificate expiry", "Eebus.CertExpiry", true),
103606
+ text("lte-state", "LTE state", "LTE.State"),
103607
+ text("lte-connection-type", "Connection type", "LTE.Connection.Type"),
103608
+ numeric("lte-quality", "LTE quality", "percentage", "LTE.Quality"),
103609
+ numeric("lte-rsrp", "RSRP", "signal-level", "LTE.RSRP"),
103610
+ numeric("lte-rsrq", "RSRQ", "index", "LTE.RSRQ"),
103611
+ numeric("lte-rssi", "RSSI", "signal-level", "LTE.RSSI"),
103612
+ numeric("lte-snr", "SNR", "index", "LTE.SNR"),
103613
+ text("lte-failover", "Failover result", "LTE.Failover.Result"),
103614
+ text("lte-signal-check", "Signal check", "LTE.Cronny.Result"),
103615
+ text("wifi-state", "Wi-Fi state", "WiFi.State"),
103616
+ numeric("data-lan-down", "LAN download this month", "count", "IoT.Data.Consumption.Lan.Down.Month"),
103617
+ numeric("data-lan-up", "LAN upload this month", "count", "IoT.Data.Consumption.Lan.Up.Month"),
103618
+ numeric("data-lte-down", "LTE download this month", "count", "IoT.Data.Consumption.Lte.Down.Month"),
103619
+ numeric("data-lte-up", "LTE upload this month", "count", "IoT.Data.Consumption.Lte.Up.Month")
102487
103620
  ];
102488
103621
  function findSpec(id) {
102489
103622
  return ENPAL_SPECS.find((s) => s.id === id);
@@ -102495,6 +103628,7 @@ function capsOfSpec(spec) {
102495
103628
  case "battery": return ["battery", "power-meter"];
102496
103629
  case "meter": return ["meter"];
102497
103630
  case "numeric": return ["numeric-sensor"];
103631
+ case "text": return ["enum-sensor"];
102498
103632
  }
102499
103633
  }
102500
103634
  function pointsOf(spec) {
@@ -102508,6 +103642,7 @@ function pointsOf(spec) {
102508
103642
  ];
102509
103643
  case "meter":
102510
103644
  case "numeric": return [spec.value];
103645
+ case "text": return [];
102511
103646
  }
102512
103647
  }
102513
103648
  function resolve(target, snapshot) {
@@ -102515,7 +103650,7 @@ function resolve(target, snapshot) {
102515
103650
  for (const source of target.sources) {
102516
103651
  const reading = snapshot.readings.get(source.key);
102517
103652
  if (reading === void 0) continue;
102518
- const converted = toBase(reading.value, reading.unit, target.quantity);
103653
+ const converted = toBase(reading.value, target.ignoreUnit === true ? void 0 : reading.unit, target.quantity);
102519
103654
  if (!converted.ok) {
102520
103655
  refusal = `${source.key}: ${converted.reason} (unit ${reading.unit ?? "none"})`;
102521
103656
  continue;
@@ -102534,7 +103669,7 @@ function resolve(target, snapshot) {
102534
103669
  }
102535
103670
  /** Specs whose data the box reported: a refused point still counts, the device exists. */
102536
103671
  function availableSpecs(snapshot) {
102537
- return ENPAL_SPECS.filter((spec) => pointsOf(spec).some((p) => resolve(p, snapshot).state !== "absent"));
103672
+ return ENPAL_SPECS.filter((spec) => spec.kind === "text" ? snapshot.texts.has(spec.key) : pointsOf(spec).some((p) => resolve(p, snapshot).state !== "absent"));
102538
103673
  }
102539
103674
  function numberOf(r) {
102540
103675
  return r.state === "value" ? r.value : void 0;
@@ -102633,6 +103768,17 @@ function computeSlices(spec, snapshot, options, now) {
102633
103768
  refusals: refusalOf(value)
102634
103769
  };
102635
103770
  }
103771
+ case "text": {
103772
+ const value = snapshot.texts.get(spec.key);
103773
+ return {
103774
+ ...value !== void 0 ? { text: {
103775
+ value,
103776
+ ...spec.timestamp === true ? { timestamp: true } : {},
103777
+ lastFetchedAt: now
103778
+ } } : {},
103779
+ refusals: []
103780
+ };
103781
+ }
102636
103782
  }
102637
103783
  }
102638
103784
  //#endregion
@@ -102730,6 +103876,7 @@ var POWER_CAP = powerMeterCapability.name;
102730
103876
  var BATTERY_CAP = batteryCapability.name;
102731
103877
  var METER_CAP = meterCapability.name;
102732
103878
  var NUMERIC_CAP = numericSensorCapability.name;
103879
+ var ENUM_CAP = enumSensorCapability.name;
102733
103880
  var POWER_COLD = { lastFetchedAt: 0 };
102734
103881
  var BATTERY_COLD = {
102735
103882
  level: null,
@@ -102741,6 +103888,11 @@ var VALUE_COLD = {
102741
103888
  value: null,
102742
103889
  lastFetchedAt: 0
102743
103890
  };
103891
+ /** A text has no null: until the box sends one the slice is empty and never read as a state. */
103892
+ var TEXT_COLD = {
103893
+ value: "",
103894
+ lastFetchedAt: 0
103895
+ };
102744
103896
  /**
102745
103897
  * Persisted config of an Enpal Sensor. `specId` selects the entry of the catalog;
102746
103898
  * `integrationId` selects the integration whose box feeds it.
@@ -102778,7 +103930,8 @@ var EnpalSensorDevice = class extends BaseDevice {
102778
103930
  this.updateSourceInfo({
102779
103931
  id: `${this.integrationId}:${spec.id}`,
102780
103932
  system: ENPAL_SYSTEM,
102781
- ...spec.kind === "meter" || spec.kind === "numeric" ? { quantity: spec.quantity } : {}
103933
+ ...spec.kind === "meter" || spec.kind === "numeric" ? { quantity: spec.quantity } : {},
103934
+ ...spec.kind === "text" && spec.timestamp === true ? { dateTimeShape: "date" } : {}
102782
103935
  });
102783
103936
  }
102784
103937
  async onActivate() {
@@ -102836,6 +103989,7 @@ var EnpalSensorDevice = class extends BaseDevice {
102836
103989
  });
102837
103990
  }
102838
103991
  if (slices.numeric !== void 0) this.runtimeState.setCapState(NUMERIC_CAP, slices.numeric);
103992
+ if (slices.text !== void 0) this.runtimeState.setCapState(ENUM_CAP, slices.text);
102839
103993
  }
102840
103994
  registerCaps() {
102841
103995
  switch (this.spec.kind) {
@@ -102850,6 +104004,10 @@ var EnpalSensorDevice = class extends BaseDevice {
102850
104004
  this.ctx.registerNativeCap(meterCapability, { getStatus: async () => this.runtimeState.getCapState(METER_CAP) ?? VALUE_COLD });
102851
104005
  this.runtimeState.setCapState(METER_CAP, VALUE_COLD);
102852
104006
  return;
104007
+ case "text":
104008
+ this.ctx.registerNativeCap(enumSensorCapability, { getStatus: async () => this.runtimeState.getCapState(ENUM_CAP) ?? TEXT_COLD });
104009
+ this.runtimeState.setCapState(ENUM_CAP, TEXT_COLD);
104010
+ return;
102853
104011
  case "numeric":
102854
104012
  this.ctx.registerNativeCap(numericSensorCapability, { getStatus: async () => this.runtimeState.getCapState(NUMERIC_CAP) ?? VALUE_COLD });
102855
104013
  this.runtimeState.setCapState(NUMERIC_CAP, VALUE_COLD);
@@ -102879,43 +104037,166 @@ function enpalSensorFactory(ctx) {
102879
104037
  }
102880
104038
  //#endregion
102881
104039
  //#region src/enpal-equipment.ts
102882
- /** Order is the order the candidates are listed in. Every catalog entry belongs to one. */
104040
+ var live = (section, ...specIds) => specIds.map((specId) => ({
104041
+ specId,
104042
+ ...section !== void 0 ? { section } : {},
104043
+ diagnostic: false
104044
+ }));
104045
+ var diag = (section, ...specIds) => specIds.map((specId) => ({
104046
+ specId,
104047
+ ...section !== void 0 ? { section } : {},
104048
+ diagnostic: true
104049
+ }));
104050
+ function equipment(input) {
104051
+ return {
104052
+ ...input,
104053
+ specIds: input.children.map((c) => c.specId)
104054
+ };
104055
+ }
104056
+ /**
104057
+ * Three adoptable pieces: the box (the Gateway, with its connection, firmware and
104058
+ * diagnostics), the installation (System: Sungrow inverter, battery, grid, house meter), and
104059
+ * the heat pump as the box reads it. Order is the order the candidates are listed in. Every
104060
+ * catalog entry belongs to one. The ids of the older catalog entries are unchanged.
104061
+ */
102883
104062
  var ENPAL_EQUIPMENT = [
102884
- {
102885
- id: "pv",
102886
- name: "Photovoltaic",
102887
- specIds: ["pv", "inverter-temperature"]
102888
- },
102889
- {
102890
- id: "battery",
102891
- name: "Battery",
102892
- specIds: [
104063
+ equipment({
104064
+ id: "box",
104065
+ kind: "gateway",
104066
+ name: "Enpal Box",
104067
+ children: [
104068
+ ...diag("Box", "box-main-state", "box-cpu-load", "box-memory-usage", "box-hardware-check"),
104069
+ ...diag("EEBUS", "eebus-connection", "eebus-discovery", "eebus-cert-expiry"),
104070
+ ...diag("Connectivity", "lte-state", "lte-connection-type", "lte-quality", "lte-rsrp", "lte-rsrq", "lte-rssi", "lte-snr", "lte-failover", "lte-signal-check", "wifi-state", "data-lan-down", "data-lan-up", "data-lte-down", "data-lte-up")
104071
+ ],
104072
+ metadata: [
104073
+ {
104074
+ attribute: "deviceName",
104075
+ key: "Device.Name"
104076
+ },
104077
+ {
104078
+ attribute: "model",
104079
+ key: "IoTEdgeDevice.Device.Type"
104080
+ },
104081
+ {
104082
+ attribute: "serialNumber",
104083
+ key: "IoTEdgeDevice.SerialNumber"
104084
+ },
104085
+ {
104086
+ attribute: "hardware",
104087
+ key: "Generation.HW"
104088
+ },
104089
+ {
104090
+ attribute: "softwareGeneration",
104091
+ key: "Generation.SW"
104092
+ },
104093
+ {
104094
+ attribute: "eebusSki",
104095
+ key: "Eebus.Ski"
104096
+ },
104097
+ {
104098
+ attribute: "modemModel",
104099
+ key: "LTE.Modem.Type"
104100
+ },
104101
+ {
104102
+ attribute: "modemFirmware",
104103
+ key: "LTE.Modem.Firmware.Version"
104104
+ }
104105
+ ],
104106
+ primaryChildPreference: []
104107
+ }),
104108
+ equipment({
104109
+ id: "impianto",
104110
+ kind: "container",
104111
+ name: "System",
104112
+ children: [
104113
+ ...live("Photovoltaic", "pv", "pv-string-1-power", "pv-string-2-power", "pv-energy-day"),
104114
+ ...diag("Photovoltaic", "pv-string-1-voltage", "pv-string-2-voltage", "pv-string-1-current", "pv-string-2-current"),
104115
+ ...live("Battery", "battery", "battery-stored-energy", "battery-temperature", "battery-charge-day", "battery-discharge-day", "battery-charged", "battery-discharged"),
104116
+ ...diag("Battery", "battery-health", "battery-capacity", "battery-power-rating", "battery-level-max", "battery-level-min", "battery-power-set", "battery-charge-power-max", "battery-discharge-power-max", "battery-charge-current-max", "battery-discharge-current-max"),
104117
+ ...live("Grid", "grid-import", "grid-export", "grid-phase-a", "grid-phase-b", "grid-phase-c", "grid-import-day", "grid-export-day"),
104118
+ ...diag("Grid", "grid-voltage-a", "grid-voltage-b", "grid-voltage-c", "grid-frequency", "grid-feed-max"),
104119
+ ...live("Home", "house", "house-energy-day"),
104120
+ ...live("Inverter", "inverter-state", "inverter-ems-mode", "inverter-ac-power"),
104121
+ ...diag("Inverter", "inverter-start-stop", "inverter-forcible-mode", "inverter-temperature", "inverter-power-rating", "inverter-phase-type", "inverter-modbus-rtu", "inverter-modbus-tcp")
104122
+ ],
104123
+ metadata: [
104124
+ {
104125
+ attribute: "model",
104126
+ key: "Code.Device.Type"
104127
+ },
104128
+ {
104129
+ attribute: "firmware",
104130
+ key: "SoftwareVersion.Service.1"
104131
+ },
104132
+ {
104133
+ attribute: "communicationFirmware",
104134
+ key: "SoftwareVersion.Service.2"
104135
+ },
104136
+ {
104137
+ attribute: "dongleSerialNumber",
104138
+ key: "Dongle.SerialNumber"
104139
+ },
104140
+ {
104141
+ attribute: "batterySerialNumber",
104142
+ key: "SerialNumber.Battery"
104143
+ },
104144
+ {
104145
+ attribute: "batteryFirmware",
104146
+ key: "SoftwareVersion.Service.Battery.Unit.1"
104147
+ }
104148
+ ],
104149
+ primaryChildPreference: [
104150
+ "pv",
102893
104151
  "battery",
102894
- "battery-charged",
102895
- "battery-discharged",
102896
- "battery-temperature",
102897
- "battery-health"
104152
+ "house"
102898
104153
  ]
102899
- },
102900
- {
102901
- id: "grid",
102902
- name: "Grid",
102903
- specIds: ["grid-import", "grid-export"]
102904
- },
102905
- {
102906
- id: "house",
102907
- name: "House",
102908
- specIds: ["house"]
102909
- },
102910
- {
104154
+ }),
104155
+ equipment({
102911
104156
  id: "heatpump",
102912
- name: "Heat pump",
102913
- specIds: [
104157
+ kind: "container",
104158
+ name: "Heat pump (Enpal)",
104159
+ children: [...live(void 0, "heatpump", "heatpump-hot-water", "heatpump-outside", "heatpump-hot-water-mode", "heatpump-heating-mode", "heatpump-generator-state", "heatpump-heater-state"), ...diag(void 0, "heatpump-energy-management", "heatpump-power-desired", "heatpump-pv-optimization", "heatpump-power-min", "heatpump-power-max", "heatpump-connection")],
104160
+ metadata: [
104161
+ {
104162
+ attribute: "model",
104163
+ key: "Heatpump.IndoorUnit.DeviceType"
104164
+ },
104165
+ {
104166
+ attribute: "serialNumber",
104167
+ key: "Heatpump.SerialNumber"
104168
+ },
104169
+ {
104170
+ attribute: "indoorUnitSerialNumber",
104171
+ key: "Heatpump.IndoorUnit.SerialNumber"
104172
+ },
104173
+ {
104174
+ attribute: "firmware",
104175
+ key: "Heatpump.SoftwareVersion"
104176
+ },
104177
+ {
104178
+ attribute: "indoorUnitFirmware",
104179
+ key: "Heatpump.IndoorUnit.SoftwareVersion"
104180
+ },
104181
+ {
104182
+ attribute: "gatewayModel",
104183
+ key: "Heatpump.Gateway.Device.Type"
104184
+ },
104185
+ {
104186
+ attribute: "gatewaySerialNumber",
104187
+ key: "Heatpump.Gateway.SerialNumber"
104188
+ },
104189
+ {
104190
+ attribute: "gatewayFirmware",
104191
+ key: "Heatpump.Gateway.SoftwareVersion"
104192
+ }
104193
+ ],
104194
+ primaryChildPreference: [
102914
104195
  "heatpump",
102915
104196
  "heatpump-hot-water",
102916
104197
  "heatpump-outside"
102917
104198
  ]
102918
- }
104199
+ })
102919
104200
  ];
102920
104201
  function findEquipment(id) {
102921
104202
  return ENPAL_EQUIPMENT.find((e) => e.id === id);
@@ -102929,24 +104210,61 @@ function childNativeId(equipmentId, specId) {
102929
104210
  * they stay, whatever the box reports later, so a restart with the box off keeps them.
102930
104211
  */
102931
104212
  var enpalEquipmentSchema = object({
102932
- equipment: string().min(1).describe("Equipment id (pv, battery, grid, house, heatpump)"),
104213
+ equipment: string().min(1).describe("Equipment id (impianto, heatpump; older adoptions: pv, battery, grid, house)"),
102933
104214
  specIds: array(string().min(1)).describe("Catalog entries adopted as children"),
102934
104215
  integrationId: string().min(1).describe("Owning integration"),
102935
104216
  system: literal(ENPAL_SYSTEM).optional()
102936
104217
  });
102937
- /** The Sensor children the kernel spawns under an equipment Container. */
104218
+ /**
104219
+ * The declared primary children of an adopted piece: its preference, limited to the children
104220
+ * it was adopted with. Empty for the gateway and for an equipment id the catalog dropped.
104221
+ */
104222
+ function primaryChildPreferenceOf(found, specIds) {
104223
+ if (found === void 0) return [];
104224
+ return found.primaryChildPreference.filter((id) => specIds.includes(id));
104225
+ }
104226
+ /**
104227
+ * The accordion layout of an equipment Container, for the children it was adopted with.
104228
+ * Empty for a Container with no sections (the heat pump) or an unknown equipment.
104229
+ */
104230
+ function childLayoutOf(found, specIds) {
104231
+ if (found === void 0) return [];
104232
+ const entries = [];
104233
+ found.children.forEach((child, order) => {
104234
+ if (child.section === void 0 || !specIds.includes(child.specId)) return;
104235
+ entries.push({
104236
+ childKey: child.specId,
104237
+ section: child.section,
104238
+ order
104239
+ });
104240
+ });
104241
+ return entries;
104242
+ }
104243
+ /** The identity facts the box reported for an equipment, by device attribute. */
104244
+ function metadataOf(found, snapshot) {
104245
+ const out = {};
104246
+ for (const field of found?.metadata ?? []) {
104247
+ const value = snapshot.texts.get(field.key);
104248
+ if (value !== void 0) out[field.attribute] = value;
104249
+ }
104250
+ return out;
104251
+ }
104252
+ /** The Sensor children the kernel spawns under an adopted piece (a Container or the Gateway). */
102938
104253
  function accessoryChildSpecs(config) {
104254
+ const found = findEquipment(config.equipment);
102939
104255
  const out = [];
102940
104256
  for (const specId of config.specIds) {
102941
104257
  const spec = findSpec(specId);
102942
104258
  if (spec === void 0) continue;
104259
+ const diagnostic = found?.children.find((c) => c.specId === specId)?.diagnostic === true;
102943
104260
  out.push({
102944
104261
  stableIdSuffix: spec.id,
102945
104262
  meta: {
102946
104263
  type: DeviceType.Sensor,
102947
104264
  name: spec.name,
102948
104265
  role: spec.role,
102949
- integrationId: config.integrationId
104266
+ integrationId: config.integrationId,
104267
+ ...diagnostic ? { display: { hidden: true } } : {}
102950
104268
  },
102951
104269
  config: {
102952
104270
  specId: spec.id,
@@ -102959,18 +104277,87 @@ function accessoryChildSpecs(config) {
102959
104277
  return out;
102960
104278
  }
102961
104279
  //#endregion
104280
+ //#region src/devices/enpal-metadata-sync.ts
104281
+ /**
104282
+ * Writes the identity facts a piece reported (model, serial, firmware) as device attributes.
104283
+ * A poll that changed none writes nothing; a failed write is logged and retried next poll.
104284
+ */
104285
+ var EnpalMetadataSync = class {
104286
+ options;
104287
+ applied = "";
104288
+ constructor(options) {
104289
+ this.options = options;
104290
+ }
104291
+ async apply() {
104292
+ const snapshot = this.options.getSnapshot();
104293
+ if (snapshot === null) return;
104294
+ const attributes = metadataOf(findEquipment(this.options.equipment), snapshot);
104295
+ const fingerprint = JSON.stringify(attributes);
104296
+ if (fingerprint === this.applied || Object.keys(attributes).length === 0) return;
104297
+ try {
104298
+ await this.options.write(attributes);
104299
+ this.applied = fingerprint;
104300
+ } catch (err) {
104301
+ this.options.logger.warn("enpal: could not write the device attributes", {
104302
+ tags: { deviceId: this.options.deviceId },
104303
+ meta: {
104304
+ equipment: this.options.equipment,
104305
+ error: errMsg(err)
104306
+ }
104307
+ });
104308
+ }
104309
+ }
104310
+ };
104311
+ //#endregion
104312
+ //#region src/devices/enpal-preference-backfill.ts
104313
+ /**
104314
+ * Stamp the declared primary-child preference on a container adopted before
104315
+ * the declaration existed. Adoption stamps it through `InitialDeviceMeta`;
104316
+ * a restore passes no initial meta, so an older row would otherwise never get
104317
+ * one and keep being shown by the heuristic's child.
104318
+ *
104319
+ * Only an ABSENT declaration is filled: a stored one is the provider's own and
104320
+ * is left alone. The operator's pick (`primaryChildEntityId`) is a different
104321
+ * column, never written here, and still wins at resolution.
104322
+ *
104323
+ * @returns whether it wrote.
104324
+ */
104325
+ async function backfillPrimaryChildPreference(input) {
104326
+ const { deviceId, stored, declared, write, logger } = input;
104327
+ if (stored !== void 0 && stored.length > 0) return false;
104328
+ if (declared.length === 0) return false;
104329
+ try {
104330
+ await write(declared);
104331
+ } catch (err) {
104332
+ logger.warn("enpal: could not stamp the primary-child preference", {
104333
+ tags: { deviceId },
104334
+ meta: { error: errMsg(err) }
104335
+ });
104336
+ return false;
104337
+ }
104338
+ logger.info("enpal: stamped the primary-child preference on a container adopted without one", {
104339
+ tags: { deviceId },
104340
+ meta: { primaryChildPreference: declared }
104341
+ });
104342
+ return true;
104343
+ }
104344
+ //#endregion
102962
104345
  //#region src/devices/enpal-equipment-device.ts
102963
104346
  /**
102964
- * One physical piece of the installation (photovoltaic, battery, grid, house, heat pump) as a
104347
+ * One piece Enpal measures (the System: PV, battery, grid, house; the heat pump) as a
102965
104348
  * {@link DeviceType.Container}. It owns no caps: it declares the Sensor children it was
102966
- * adopted with, and the kernel spawns and restores them. Online follows the integration's
102967
- * box; the integration marks it offline when a poll fails.
104349
+ * adopted with, and the kernel spawns and restores them; which child stands for it is the
104350
+ * `primaryChildPreference` stamped at adoption, and re-stamped on activation for a
104351
+ * container adopted before it had one. Its model, serials and firmware are device
104352
+ * attributes, written from the box's text rows whenever they change. Online follows the
104353
+ * integration's box; the integration marks it offline when a poll fails.
102968
104354
  */
102969
104355
  var EnpalEquipmentDevice = class extends BaseDevice {
102970
104356
  features = [];
102971
104357
  equipment;
102972
104358
  specIds;
102973
104359
  integrationId;
104360
+ snapshotUnsub = null;
102974
104361
  constructor(ctx) {
102975
104362
  const cfg = enpalEquipmentSchema.parse(ctx.persistedConfig ?? {});
102976
104363
  super(ctx, enpalEquipmentSchema, { type: DeviceType.Container });
@@ -102983,6 +104370,42 @@ var EnpalEquipmentDevice = class extends BaseDevice {
102983
104370
  system: ENPAL_SYSTEM
102984
104371
  });
102985
104372
  }
104373
+ async onActivate() {
104374
+ await super.onActivate();
104375
+ await backfillPrimaryChildPreference({
104376
+ deviceId: this.id,
104377
+ stored: this.ctx.deviceMeta.primaryChildPreference,
104378
+ declared: primaryChildPreferenceOf(findEquipment(this.equipment), this.specIds),
104379
+ write: async (keys) => {
104380
+ await this.ctx.api.deviceManager.setPrimaryChildPreference.mutate({
104381
+ deviceId: this.id,
104382
+ primaryChildPreference: keys
104383
+ });
104384
+ },
104385
+ logger: this.ctx.logger
104386
+ });
104387
+ const metadata = new EnpalMetadataSync({
104388
+ equipment: this.equipment,
104389
+ deviceId: this.id,
104390
+ getSnapshot: () => enpalConnections.getSnapshot(this.integrationId),
104391
+ write: async (patch) => {
104392
+ await this.ctx.api.deviceManager.setMetadata.mutate({
104393
+ deviceId: this.id,
104394
+ patch
104395
+ });
104396
+ },
104397
+ logger: this.ctx.logger
104398
+ });
104399
+ this.snapshotUnsub = enpalConnections.onSnapshot(this.integrationId, () => {
104400
+ metadata.apply();
104401
+ });
104402
+ await metadata.apply();
104403
+ }
104404
+ async removeDevice() {
104405
+ this.snapshotUnsub?.();
104406
+ this.snapshotUnsub = null;
104407
+ await super.removeDevice();
104408
+ }
102986
104409
  getAccessoryChildren() {
102987
104410
  return accessoryChildSpecs({
102988
104411
  equipment: this.equipment,
@@ -102991,33 +104414,56 @@ var EnpalEquipmentDevice = class extends BaseDevice {
102991
104414
  });
102992
104415
  }
102993
104416
  };
104417
+ //#endregion
104418
+ //#region src/enpal-discovery.ts
102994
104419
  var METADATA = {
102995
104420
  manufacturer: "Enpal",
102996
104421
  integration: "enpal"
102997
104422
  };
104423
+ /** The Sensor candidates of one piece: the catalog entries of it the box reported. */
104424
+ function reportedChildren(equipment, snapshot) {
104425
+ if (snapshot === null) return [];
104426
+ const reported = new Map(availableSpecs(snapshot).map((spec) => [spec.id, spec]));
104427
+ return equipment.specIds.flatMap((id) => {
104428
+ const spec = reported.get(id);
104429
+ if (spec === void 0) return [];
104430
+ return [{
104431
+ childNativeId: childNativeId(equipment.id, spec.id),
104432
+ name: spec.name,
104433
+ type: DeviceType.Sensor,
104434
+ status: "online",
104435
+ metadata: METADATA,
104436
+ alreadyAdopted: false,
104437
+ adoptedDeviceId: null,
104438
+ capabilities: capsOfSpec(spec)
104439
+ }];
104440
+ });
104441
+ }
102998
104442
  /**
102999
- * The box ({@link DeviceType.Gateway}, always offered, offline while it is unreachable), then one
103000
- * Container candidate per equipment the box reported at least one value for, with the
103001
- * Sensor children it reported. Pure.
104443
+ * The box ({@link DeviceType.Gateway}, always offered, offline while it is unreachable, with the
104444
+ * diagnostics it reported as children), then one Container candidate per other piece the box
104445
+ * reported at least one value for, with the Sensor children it reported. Pure.
103002
104446
  */
103003
104447
  function buildEnpalCandidates(input) {
103004
104448
  const { snapshot } = input;
103005
104449
  const boxAdopted = input.adopted.get("box") ?? null;
104450
+ const box = findEquipment("box");
104451
+ const boxChildren = box === void 0 ? [] : reportedChildren(box, snapshot);
103006
104452
  const out = [{
103007
104453
  childNativeId: "box",
103008
- name: input.box.name,
104454
+ name: gatewayDeviceName("Enpal", "Box"),
103009
104455
  type: DeviceType.Gateway,
103010
104456
  status: input.box.online ? "online" : "offline",
103011
104457
  metadata: METADATA,
103012
104458
  alreadyAdopted: boxAdopted !== null,
103013
104459
  adoptedDeviceId: boxAdopted,
103014
- capabilities: [deviceAdminLinkCapability.name]
104460
+ capabilities: [deviceAdminLinkCapability.name],
104461
+ ...boxChildren.length > 0 ? { children: boxChildren } : {}
103015
104462
  }];
103016
- if (snapshot === null) return out;
103017
- const reported = new Map(availableSpecs(snapshot).map((spec) => [spec.id, spec]));
103018
104463
  for (const equipment of ENPAL_EQUIPMENT) {
103019
- const specs = equipment.specIds.flatMap((id) => reported.get(id) ?? []);
103020
- if (specs.length === 0) continue;
104464
+ if (equipment.kind !== "container") continue;
104465
+ const children = reportedChildren(equipment, snapshot);
104466
+ if (children.length === 0) continue;
103021
104467
  const adoptedId = input.adopted.get(equipment.id) ?? null;
103022
104468
  out.push({
103023
104469
  childNativeId: equipment.id,
@@ -103028,16 +104474,7 @@ function buildEnpalCandidates(input) {
103028
104474
  alreadyAdopted: adoptedId !== null,
103029
104475
  adoptedDeviceId: adoptedId,
103030
104476
  capabilities: [],
103031
- children: specs.map((spec) => ({
103032
- childNativeId: childNativeId(equipment.id, spec.id),
103033
- name: spec.name,
103034
- type: DeviceType.Sensor,
103035
- status: "online",
103036
- metadata: METADATA,
103037
- alreadyAdopted: false,
103038
- adoptedDeviceId: null,
103039
- capabilities: capsOfSpec(spec)
103040
- }))
104477
+ children
103041
104478
  });
103042
104479
  }
103043
104480
  return out;
@@ -103051,7 +104488,12 @@ var DEVICES_FILTER = {
103051
104488
  };
103052
104489
  function buildEnpalAdoptionProvider(deps) {
103053
104490
  const { getSnapshot, hasIntegration, listIntegrations, listAdopted, logger } = deps;
103054
- const boxName = (integrationId) => deps.integrationName(integrationId) ?? "Enpal";
104491
+ const boxName = (integrationId) => deps.integrationName(integrationId) ?? findEquipment("box")?.name ?? "Enpal Box";
104492
+ /** The catalog entries of a piece the offered candidate reports as children. */
104493
+ const reportedSpecIds = (equipment, candidate) => {
104494
+ const reported = new Set((candidate?.children ?? []).map((c) => c.childNativeId));
104495
+ return equipment.specIds.filter((id) => reported.has(`${equipment.id}#${id}`));
104496
+ };
103055
104497
  async function adoptedMap(integrationId) {
103056
104498
  const map = /* @__PURE__ */ new Map();
103057
104499
  for (const device of await listAdopted()) {
@@ -103127,20 +104569,23 @@ function buildEnpalAdoptionProvider(deps) {
103127
104569
  const adopted = [];
103128
104570
  let failures = 0;
103129
104571
  for (const equipmentId of childNativeIds) try {
103130
- if (equipmentId === "box") {
103131
- const { deviceId } = await deps.adoptGateway({
104572
+ const candidate = offered.find((c) => c.childNativeId === equipmentId);
104573
+ const equipment = findEquipment(equipmentId);
104574
+ if (equipmentId === "box" && equipment !== void 0) {
104575
+ const specIds = reportedSpecIds(equipment, candidate);
104576
+ const { deviceId, accessoryDeviceIds } = await deps.adoptGateway({
103132
104577
  integrationId,
103133
- name: perCandidate?.[equipmentId]?.name ?? boxName(integrationId)
104578
+ name: perCandidate?.[equipmentId]?.name ?? gatewayDeviceName("Enpal", "Box"),
104579
+ specIds,
104580
+ childLayout: childLayoutOf(equipment, specIds)
103134
104581
  });
103135
104582
  adopted.push({
103136
104583
  childNativeId: equipmentId,
103137
104584
  parentDeviceId: deviceId,
103138
- accessoryDeviceIds: []
104585
+ accessoryDeviceIds
103139
104586
  });
103140
104587
  continue;
103141
104588
  }
103142
- const candidate = offered.find((c) => c.childNativeId === equipmentId);
103143
- const equipment = findEquipment(equipmentId);
103144
104589
  if (candidate === void 0 || equipment === void 0) {
103145
104590
  logger.warn("enpal adopt: the box reports nothing for this equipment — skipping", { meta: {
103146
104591
  equipment: equipmentId,
@@ -103149,13 +104594,14 @@ function buildEnpalAdoptionProvider(deps) {
103149
104594
  failures++;
103150
104595
  continue;
103151
104596
  }
103152
- const reported = new Set((candidate.children ?? []).map((c) => c.childNativeId));
103153
- const specIds = equipment.specIds.filter((id) => reported.has(`${equipment.id}#${id}`));
104597
+ const specIds = reportedSpecIds(equipment, candidate);
103154
104598
  const { deviceId, accessoryDeviceIds } = await deps.adoptDevice({
103155
104599
  equipment: equipment.id,
103156
104600
  specIds,
103157
104601
  integrationId,
103158
- name: perCandidate?.[equipmentId]?.name ?? equipment.name
104602
+ name: perCandidate?.[equipmentId]?.name ?? equipment.name,
104603
+ childLayout: childLayoutOf(equipment, specIds),
104604
+ primaryChildPreference: primaryChildPreferenceOf(equipment, specIds)
103159
104605
  });
103160
104606
  adopted.push({
103161
104607
  childNativeId: equipmentId,
@@ -103186,10 +104632,187 @@ function buildEnpalAdoptionProvider(deps) {
103186
104632
  }
103187
104633
  };
103188
104634
  }
104635
+ var BlazorRowsSchema = array(object({
104636
+ key: string(),
104637
+ value: string(),
104638
+ unit: string().nullable(),
104639
+ timestamp: string().nullable()
104640
+ }));
104641
+ var BridgeResultSchema = discriminatedUnion("ok", [object({
104642
+ ok: literal(true),
104643
+ rows: BlazorRowsSchema
104644
+ }), object({
104645
+ ok: literal(false),
104646
+ kind: _enum([
104647
+ "unreachable",
104648
+ "bad-response",
104649
+ "empty",
104650
+ "protocol"
104651
+ ]),
104652
+ error: string()
104653
+ })]);
104654
+ /** A failed bridge run. The message never carries the bridge's stderr beyond a short tail. */
104655
+ var BlazorBridgeError = class extends Error {
104656
+ kind;
104657
+ constructor(kind, message) {
104658
+ super(message);
104659
+ this.name = "BlazorBridgeError";
104660
+ this.kind = kind;
104661
+ }
104662
+ };
104663
+ /** The worker waits up to 12 s for the box's first render; this leaves room for connect and exit. */
104664
+ var DEFAULT_TIMEOUT_MS = 4e4;
104665
+ var WINDOW_MS = 12e3;
104666
+ /** A real answer is ~25 KB; anything near this is a runaway bridge, not a box. */
104667
+ var MAX_STDOUT_BYTES = 2 * 1024 * 1024;
104668
+ /**
104669
+ * How long `auto` leaves the Blazor channel alone after it failed. An older box that never
104670
+ * serves it would otherwise cost the full read window on every poll.
104671
+ */
104672
+ var BLAZOR_BACKOFF_MS = 10 * 6e4;
104673
+ /** The slowest a good read takes: connect, the 12 s render window, exit. */
104674
+ var BLAZOR_WORST_CASE_READ_MS = 2e4;
104675
+ /**
104676
+ * The poll interval actually used. A box-web-channel read can take most of
104677
+ * {@link BLAZOR_WORST_CASE_READ_MS}, so a shorter interval is raised to it when that channel
104678
+ * can be the source: ticks would only pile onto the running read.
104679
+ */
104680
+ function effectivePollIntervalMs(connection) {
104681
+ return connection.source === "auto" || connection.source === "blazor" ? Math.max(connection.pollIntervalMs, BLAZOR_WORST_CASE_READ_MS) : connection.pollIntervalMs;
104682
+ }
104683
+ /** Remembers a failed Blazor read so `auto` skips it for a while. Forced `blazor` ignores it. */
104684
+ var BlazorBackoff = class {
104685
+ #until = 0;
104686
+ #reason = null;
104687
+ /** Why the channel is being skipped now, or null when it may be tried. */
104688
+ skipReason(now) {
104689
+ if (this.#reason === null || now >= this.#until) return null;
104690
+ return {
104691
+ kind: this.#reason.kind,
104692
+ message: `${this.#reason.message}; the box web channel is skipped for ${BLAZOR_BACKOFF_MS / 6e4} minutes after a failure`
104693
+ };
104694
+ }
104695
+ failed(now, reason) {
104696
+ this.#until = now + BLAZOR_BACKOFF_MS;
104697
+ this.#reason = reason;
104698
+ }
104699
+ recovered() {
104700
+ this.#until = 0;
104701
+ this.#reason = null;
104702
+ }
104703
+ };
104704
+ /** One bridge run per poll: spawn, write the request on stdin, parse one JSON answer. */
104705
+ function createBlazorReader(deps) {
104706
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
104707
+ return (target) => new Promise((resolve, reject) => {
104708
+ const child = (0, node_child_process.spawn)(deps.pythonPath, [deps.scriptPath], { stdio: [
104709
+ "pipe",
104710
+ "pipe",
104711
+ "pipe"
104712
+ ] });
104713
+ let stdout = "";
104714
+ let stderr = "";
104715
+ let settled = false;
104716
+ const finish = (action) => {
104717
+ if (settled) return;
104718
+ settled = true;
104719
+ clearTimeout(timer);
104720
+ action();
104721
+ };
104722
+ const timer = setTimeout(() => {
104723
+ child.kill("SIGKILL");
104724
+ finish(() => reject(new BlazorBridgeError("timeout", `Enpal box ${target.host} did not answer within ${timeoutMs / 1e3}s`)));
104725
+ }, timeoutMs);
104726
+ child.stdout.on("data", (chunk) => {
104727
+ stdout += chunk.toString("utf8");
104728
+ if (stdout.length > MAX_STDOUT_BYTES) {
104729
+ child.kill("SIGKILL");
104730
+ finish(() => reject(new BlazorBridgeError("protocol", "the Enpal bridge wrote more output than any box answer")));
104731
+ }
104732
+ });
104733
+ child.stderr.on("data", (chunk) => {
104734
+ stderr = (stderr + chunk.toString("utf8")).slice(-16384);
104735
+ });
104736
+ child.on("error", (err) => {
104737
+ finish(() => reject(new BlazorBridgeError("protocol", `cannot start the Enpal bridge: ${err.message}`)));
104738
+ });
104739
+ child.on("close", (code) => {
104740
+ finish(() => {
104741
+ let json;
104742
+ try {
104743
+ json = JSON.parse(stdout.trim());
104744
+ } catch {
104745
+ const tail = stderr.trim().slice(-300) || "no stderr";
104746
+ reject(new BlazorBridgeError("protocol", `the Enpal bridge produced no JSON (exit ${code ?? "null"}): ${tail}`));
104747
+ return;
104748
+ }
104749
+ const parsed = BridgeResultSchema.safeParse(json);
104750
+ if (!parsed.success) {
104751
+ reject(new BlazorBridgeError("protocol", "the Enpal bridge answered an unknown shape"));
104752
+ return;
104753
+ }
104754
+ if (!parsed.data.ok) {
104755
+ reject(new BlazorBridgeError(parsed.data.kind, parsed.data.error));
104756
+ return;
104757
+ }
104758
+ resolve(parsed.data.rows);
104759
+ });
104760
+ });
104761
+ child.stdin.on("error", () => void 0);
104762
+ child.stdin.end(JSON.stringify({
104763
+ host: target.host,
104764
+ port: target.port,
104765
+ windowMs: WINDOW_MS
104766
+ }));
104767
+ });
104768
+ }
104769
+ var KEY_SHAPE$2 = /^[A-Z][A-Za-z0-9]*(\.[A-Za-z0-9-]+)+$/;
104770
+ /** A bare decimal number: `3.6`, `-40`, `0`. A version, a hash or a timestamp is not one. */
104771
+ var NUMBER = /^-?\d+(?:[.,]\d+)?$/;
104772
+ /**
104773
+ * The numeric sensors of the bridge's rows. A text value (a state name, a serial number, the
104774
+ * inverter's markup bitfield) is not a reading, and neither is a dotted string that only
104775
+ * looks like a key (`V1.1.7`, a version).
104776
+ */
104777
+ function blazorRowsToReadings(rows) {
104778
+ const readings = [];
104779
+ for (const row of rows) {
104780
+ if (!KEY_SHAPE$2.test(row.key)) continue;
104781
+ const text = row.value.trim();
104782
+ if (!NUMBER.test(text)) continue;
104783
+ const value = Number(text.replace(",", "."));
104784
+ if (!Number.isFinite(value)) continue;
104785
+ readings.push({
104786
+ key: row.key,
104787
+ value,
104788
+ unit: normalizeEnpalUnit(row.unit ?? void 0),
104789
+ measurement: void 0
104790
+ });
104791
+ }
104792
+ return readings;
104793
+ }
104794
+ /**
104795
+ * Every row's text as delivered, for state labels, serial numbers and firmware strings. A
104796
+ * row with no value, or whose value is markup (the inverter's `<ul>` bitfield), carries no
104797
+ * text worth showing and is left out.
104798
+ */
104799
+ function blazorRowsToTexts(rows) {
104800
+ const texts = [];
104801
+ for (const row of rows) {
104802
+ if (!KEY_SHAPE$2.test(row.key)) continue;
104803
+ const value = row.value.trim();
104804
+ if (value.length === 0 || value.startsWith("<")) continue;
104805
+ texts.push({
104806
+ key: row.key,
104807
+ value
104808
+ });
104809
+ }
104810
+ return texts;
104811
+ }
103189
104812
  //#endregion
103190
104813
  //#region src/enpal-device-page.ts
103191
- var CARD_HEADER = /<div class="card-header">\s*<h2>([^<]+)<\/h2>/g;
103192
- var ROW = /<tr>\s*<td>([^<]+)<\/td>\s*<td>([^<]*)<\/td>\s*<td>[^<]*<\/td>\s*<\/tr>/g;
104814
+ var CARD_HEADER = /<div class="card-header[^"]*">\s*<h2>([^<]+)<\/h2>/g;
104815
+ var ROW = /<tr(?:\s[^>]*)?>\s*<td>([^<]+)<\/td>\s*<td>([^<]*)<\/td>\s*<td>[^<]*<\/td>\s*<\/tr>/g;
103193
104816
  var KEY_SHAPE$1 = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9-]+)+$/;
103194
104817
  /** `10231W`, `-350W`, `1.85kW`, `51°C`, `62.5%`, `3` */
103195
104818
  var VALUE = /^(-?\d+(?:[.,]\d+)?)\s*(\S*)$/;
@@ -103212,7 +104835,7 @@ function cardOffsets(html) {
103212
104835
  * no zone, so it is not used: the poll time is.
103213
104836
  *
103214
104837
  * Firmware 8.51 and later pre-render only the "Site Data" card; the device rows then never
103215
- * appear and this returns `[]`.
104838
+ * appear here (they come through the Blazor circuit, `enpal-blazor.ts`).
103216
104839
  */
103217
104840
  function parseDeviceMessages(html) {
103218
104841
  const cards = cardOffsets(html);
@@ -103328,7 +104951,7 @@ function canonicalKey(key) {
103328
104951
  * Index readings by canonical key. The box writes some keys into two measurements: the first
103329
104952
  * occurrence wins.
103330
104953
  */
103331
- function createSnapshot(source, readings, fetchedAt, fallbackReason) {
104954
+ function createSnapshot(source, readings, fetchedAt, fallbackReason, texts = []) {
103332
104955
  const map = /* @__PURE__ */ new Map();
103333
104956
  for (const reading of readings) {
103334
104957
  const key = canonicalKey(reading.key);
@@ -103338,9 +104961,15 @@ function createSnapshot(source, readings, fetchedAt, fallbackReason) {
103338
104961
  key
103339
104962
  });
103340
104963
  }
104964
+ const textMap = /* @__PURE__ */ new Map();
104965
+ for (const text of texts) {
104966
+ const key = canonicalKey(text.key);
104967
+ if (!textMap.has(key)) textMap.set(key, text.value);
104968
+ }
103341
104969
  return {
103342
104970
  source,
103343
104971
  readings: map,
104972
+ texts: textMap,
103344
104973
  fetchedAt,
103345
104974
  ...fallbackReason !== void 0 ? { fallbackReason } : {}
103346
104975
  };
@@ -103399,14 +105028,42 @@ async function readInflux(input) {
103399
105028
  }
103400
105029
  }, input.fetch));
103401
105030
  if (readings.length === 0) throw new EnpalError("empty", "influxdb", `Enpal InfluxDB on ${connection.host} returned no series for bucket "${connection.bucket}" in the last two hours`);
103402
- return readings;
105031
+ return {
105032
+ readings,
105033
+ texts: []
105034
+ };
105035
+ }
105036
+ async function readBlazor(input) {
105037
+ const { connection } = input;
105038
+ if (input.blazor === void 0) throw new EnpalError("bridge-unavailable", "blazor", "The embedded Python the Enpal bridge needs is not available on this node");
105039
+ let readings;
105040
+ let texts;
105041
+ try {
105042
+ const rows = await input.blazor({
105043
+ host: connection.host,
105044
+ port: connection.pagePort
105045
+ });
105046
+ readings = blazorRowsToReadings(rows);
105047
+ texts = blazorRowsToTexts(rows);
105048
+ } catch (error) {
105049
+ if (!(error instanceof BlazorBridgeError)) throw error;
105050
+ throw new EnpalError(error.kind === "protocol" ? "bad-response" : error.kind, "blazor", error.message);
105051
+ }
105052
+ if (readings.length === 0) throw new EnpalError("empty", "blazor", `Enpal box ${connection.host} sent no numeric sensor rows`);
105053
+ return {
105054
+ readings,
105055
+ texts
105056
+ };
103403
105057
  }
103404
105058
  async function readPage(input) {
103405
105059
  const { connection } = input;
103406
105060
  const url = `http://${connection.host}:${connection.pagePort}/deviceMessages`;
103407
105061
  const readings = parseDeviceMessages(await request("device-page", connection.host, url, { method: "GET" }, input.fetch));
103408
105062
  if (readings.length === 0) throw new EnpalError("empty", "device-page", `The device page of ${connection.host} lists no device rows (firmware 8.51 and later render them only through the web socket)`);
103409
- return readings;
105063
+ return {
105064
+ readings,
105065
+ texts: []
105066
+ };
103410
105067
  }
103411
105068
  function reasonOf(error) {
103412
105069
  return {
@@ -103414,28 +105071,64 @@ function reasonOf(error) {
103414
105071
  message: error.message
103415
105072
  };
103416
105073
  }
105074
+ var READERS = {
105075
+ blazor: readBlazor,
105076
+ influxdb: readInflux,
105077
+ "device-page": readPage
105078
+ };
105079
+ /** One reason from several: the kind of the first real failure, every message. */
105080
+ function joinReasons(reasons) {
105081
+ if (reasons.length === 0) return void 0;
105082
+ return {
105083
+ kind: reasons.find((r) => r.kind !== "bridge-unavailable")?.kind ?? "bridge-unavailable",
105084
+ message: reasons.map((r) => r.message).join("; ")
105085
+ };
105086
+ }
105087
+ var PYTHON_MISSING = {
105088
+ kind: "bridge-unavailable",
105089
+ message: "Python is not available on this node, so the box web channel (the only complete source on firmware 8.51 and later) is not used"
105090
+ };
103417
105091
  /**
103418
- * Read every data point the box reports, once. `auto` tries InfluxDB first (stable, numeric)
103419
- * and falls back to the device page, recording why on the snapshot; when both fail the
103420
- * InfluxDB failure is the one reported. A failed read throws {@link EnpalError}: it never
103421
- * yields a snapshot of zeros.
105092
+ * Read every data point the box reports, once. `auto` tries the Blazor channel first (the
105093
+ * only one that carries the device tables on firmware 8.51 and later, no credential), then
105094
+ * InfluxDB when a token is set, then the device page. The snapshot records why a preferred
105095
+ * source was not used: Python missing, the channel skipped after a recent failure, or the
105096
+ * failure itself. A Blazor failure in `auto` is remembered for {@link BLAZOR_BACKOFF_MS}.
105097
+ * When every source fails the FIRST real failure is the one reported. A failed read throws
105098
+ * {@link EnpalError}: it never yields a snapshot of zeros.
103422
105099
  */
103423
105100
  async function readEnpalSnapshot(input) {
103424
- const { source } = input.connection;
103425
- if (source === "influx") return createSnapshot("influxdb", await readInflux(input), input.now());
103426
- if (source === "page") return createSnapshot("device-page", await readPage(input), input.now());
103427
- if (!(input.tokenLocked !== true && input.connection.token.trim().length > 0)) return createSnapshot("device-page", await readPage(input), input.now());
103428
- try {
103429
- return createSnapshot("influxdb", await readInflux(input), input.now());
103430
- } catch (primary) {
103431
- if (!(primary instanceof EnpalError)) throw primary;
103432
- try {
103433
- return createSnapshot("device-page", await readPage(input), input.now(), reasonOf(primary));
103434
- } catch (secondary) {
103435
- if (!(secondary instanceof EnpalError)) throw secondary;
103436
- throw primary;
105101
+ const { source: mode } = input.connection;
105102
+ const now = input.now();
105103
+ const hasToken = input.tokenLocked !== true && input.connection.token.trim().length > 0;
105104
+ const reasons = [];
105105
+ let first;
105106
+ const order = [];
105107
+ if (mode === "blazor") order.push("blazor");
105108
+ else if (mode === "influx") order.push("influxdb");
105109
+ else if (mode === "page") order.push("device-page");
105110
+ else {
105111
+ if (input.blazor === void 0) reasons.push(PYTHON_MISSING);
105112
+ else {
105113
+ const skip = input.blazorBackoff?.skipReason(now) ?? null;
105114
+ if (skip !== null) reasons.push(skip);
105115
+ else order.push("blazor");
103437
105116
  }
105117
+ if (hasToken) order.push("influxdb");
105118
+ order.push("device-page");
103438
105119
  }
105120
+ for (const source of order) try {
105121
+ const read = await READERS[source](input);
105122
+ if (source === "blazor") input.blazorBackoff?.recovered();
105123
+ return createSnapshot(source, read.readings, now, joinReasons(reasons), read.texts);
105124
+ } catch (error) {
105125
+ if (!(error instanceof EnpalError)) throw error;
105126
+ if (source === "blazor" && mode === "auto") input.blazorBackoff?.failed(now, reasonOf(error));
105127
+ first ??= error;
105128
+ reasons.push(reasonOf(error));
105129
+ }
105130
+ if (first === void 0) throw new Error("readEnpalSnapshot: no source to read");
105131
+ throw first;
103439
105132
  }
103440
105133
  //#endregion
103441
105134
  //#region src/enpal-integration-manager.ts
@@ -103450,6 +105143,9 @@ var EnpalIntegrationManager = class {
103450
105143
  #snapshot = null;
103451
105144
  #lastError = null;
103452
105145
  #timer = null;
105146
+ #blazorBackoff = new BlazorBackoff();
105147
+ #lastSource = null;
105148
+ #lastFallbackKey = null;
103453
105149
  #inFlight = null;
103454
105150
  #stopped = true;
103455
105151
  #failureLogged = false;
@@ -103481,7 +105177,7 @@ var EnpalIntegrationManager = class {
103481
105177
  if (this.#stopped || this.#timer !== null) return;
103482
105178
  this.#timer = setInterval(() => {
103483
105179
  this.poll();
103484
- }, this.#options.connection.pollIntervalMs);
105180
+ }, effectivePollIntervalMs(this.#options.connection));
103485
105181
  this.#timer.unref();
103486
105182
  }
103487
105183
  async stop() {
@@ -103501,6 +105197,19 @@ var EnpalIntegrationManager = class {
103501
105197
  this.#inFlight = run;
103502
105198
  return run;
103503
105199
  }
105200
+ /**
105201
+ * A preferred source that is not in use is said once per change of source or of reason kind (the back-off text changes every poll, the cause does not), not
105202
+ * once per boot: a box that drops from the web channel to the page mid-run is visible.
105203
+ */
105204
+ #logSourceChange(source, fallback) {
105205
+ const fallbackMessage = fallback?.message;
105206
+ const fallbackKey = fallback?.kind ?? null;
105207
+ const changed = source !== this.#lastSource || fallbackKey !== this.#lastFallbackKey;
105208
+ if (changed && fallbackMessage !== void 0) this.#options.logger.warn(`Enpal box ${this.name}: preferred source not used (${fallbackMessage}), reading ${source}`);
105209
+ else if (changed && this.#lastSource !== null && source !== this.#lastSource) this.#options.logger.info(`Enpal box ${this.name}: now reading ${source}`);
105210
+ this.#lastSource = source;
105211
+ this.#lastFallbackKey = fallbackKey;
105212
+ }
103504
105213
  async #pollOnce() {
103505
105214
  const wasConnected = this.#status === "connected";
103506
105215
  try {
@@ -103508,6 +105217,8 @@ var EnpalIntegrationManager = class {
103508
105217
  connection: this.#options.connection,
103509
105218
  tokenLocked: this.#options.tokenLocked,
103510
105219
  fetch: this.#fetch,
105220
+ blazor: this.#options.blazor?.(),
105221
+ blazorBackoff: this.#blazorBackoff,
103511
105222
  now: this.#options.now ?? Date.now
103512
105223
  });
103513
105224
  if (this.#stopped) return;
@@ -103515,7 +105226,7 @@ var EnpalIntegrationManager = class {
103515
105226
  this.#lastError = null;
103516
105227
  this.#status = "connected";
103517
105228
  if (this.#failureLogged || !wasConnected) this.#options.logger.info(`Enpal box ${this.name} reachable through ${snapshot.source} (${snapshot.readings.size} data points)`);
103518
- if (snapshot.fallbackReason !== void 0 && !wasConnected) this.#options.logger.warn(`Enpal box ${this.name}: InfluxDB not used (${snapshot.fallbackReason.kind}), reading the device page`);
105229
+ this.#logSourceChange(snapshot.source, snapshot.fallbackReason);
103519
105230
  this.#failureLogged = false;
103520
105231
  if (!wasConnected) this.#options.onConnected();
103521
105232
  this.#options.onPolled();
@@ -103572,6 +105283,7 @@ var EnpalClientRegistry = class {
103572
105283
  tokenLocked,
103573
105284
  logger: this.#logger.withTags({ integrationId }),
103574
105285
  fetch: this.#deps.fetch,
105286
+ blazor: this.#deps.blazor,
103575
105287
  now: this.#deps.now,
103576
105288
  onConnected: () => {
103577
105289
  this.#deps.onConnected(integrationId);
@@ -103618,6 +105330,26 @@ var EnpalClientRegistry = class {
103618
105330
  }
103619
105331
  };
103620
105332
  //#endregion
105333
+ //#region src/enpal-python-paths.ts
105334
+ var WORKER = "enpal_worker.py";
105335
+ /**
105336
+ * Absolute path of the bundled `python/enpal_worker.py`: next to `dist/` in a deployed
105337
+ * package, two levels up from `src/` in the workspace.
105338
+ */
105339
+ function resolveEnpalWorker(fromModuleUrl = require("url").pathToFileURL(__filename).href) {
105340
+ const here = (0, node_path.dirname)((0, node_url.fileURLToPath)(fromModuleUrl));
105341
+ const candidates = [
105342
+ (0, node_path.join)(here, "../python"),
105343
+ (0, node_path.join)(here, "../../python"),
105344
+ (0, node_path.join)(here, "python")
105345
+ ];
105346
+ for (const dir of candidates) {
105347
+ const file = (0, node_path.join)(dir, WORKER);
105348
+ if ((0, node_fs.existsSync)(file)) return file;
105349
+ }
105350
+ throw new Error(`provider-enpal: ${WORKER} not found. Searched:\n${candidates.join("\n")}`);
105351
+ }
105352
+ //#endregion
103621
105353
  //#region src/enpal-instances.ts
103622
105354
  /**
103623
105355
  * Enpal integrations as `integration-instances` (planning#416, D1007): one `connections` record
@@ -103799,23 +105531,29 @@ async function cascadeRemoveDevicesForIntegration(input) {
103799
105531
  var enpalGatewaySchema = object({
103800
105532
  equipment: literal("box"),
103801
105533
  integrationId: string().min(1).describe("Owning integration"),
103802
- system: literal(ENPAL_SYSTEM).optional()
105534
+ system: literal(ENPAL_SYSTEM).optional(),
105535
+ /** The box diagnostics it was adopted with; a box adopted before they existed has none. */
105536
+ specIds: array(string().min(1)).default([])
103803
105537
  });
103804
105538
  /**
103805
- * The Enpal box itself, as a {@link DeviceType.Gateway}. Its connection lives on the
103806
- * INTEGRATION; this device is the box's presence in the device list, and carries the one
103807
- * action the box really supports: `device-admin-link`, its local device page. The box has no
103808
- * reboot and no update. Equipment is NOT a child of it; each piece is adopted as its own
103809
- * top-level Container. Online follows the integration's box (the integration marks it
103810
- * offline when a poll fails).
105539
+ * The Enpal box itself ("Enpal Box"), as a {@link DeviceType.Gateway}. Its connection lives on
105540
+ * the INTEGRATION; this device is the box's presence in the device list: its identity and
105541
+ * firmware as device attributes, its own diagnostics (CPU, memory, LTE) as hidden Sensor
105542
+ * children, and the one action the box really supports, `device-admin-link`, its local device
105543
+ * page. The box has no reboot and no update. The System and the heat pump are NOT children of
105544
+ * it; each is adopted as its own top-level Container. Online follows the integration's box
105545
+ * (the integration marks it offline when a poll fails).
103811
105546
  */
103812
105547
  var EnpalGatewayDevice = class extends BaseDevice {
103813
105548
  features = [];
103814
105549
  integrationId;
105550
+ specIds;
105551
+ snapshotUnsub = null;
103815
105552
  constructor(ctx) {
103816
105553
  const cfg = enpalGatewaySchema.parse(ctx.persistedConfig ?? {});
103817
105554
  super(ctx, enpalGatewaySchema, { type: DeviceType.Gateway });
103818
105555
  this.integrationId = cfg.integrationId;
105556
+ this.specIds = cfg.specIds;
103819
105557
  this.online = enpalConnections.getSnapshot(this.integrationId) !== null;
103820
105558
  this.registerAdminLinkCap();
103821
105559
  this.updateSourceInfo({
@@ -103823,6 +105561,37 @@ var EnpalGatewayDevice = class extends BaseDevice {
103823
105561
  system: ENPAL_SYSTEM
103824
105562
  });
103825
105563
  }
105564
+ async onActivate() {
105565
+ await super.onActivate();
105566
+ const metadata = new EnpalMetadataSync({
105567
+ equipment: "box",
105568
+ deviceId: this.id,
105569
+ getSnapshot: () => enpalConnections.getSnapshot(this.integrationId),
105570
+ write: async (patch) => {
105571
+ await this.ctx.api.deviceManager.setMetadata.mutate({
105572
+ deviceId: this.id,
105573
+ patch
105574
+ });
105575
+ },
105576
+ logger: this.ctx.logger
105577
+ });
105578
+ this.snapshotUnsub = enpalConnections.onSnapshot(this.integrationId, () => {
105579
+ metadata.apply();
105580
+ });
105581
+ await metadata.apply();
105582
+ }
105583
+ async removeDevice() {
105584
+ this.snapshotUnsub?.();
105585
+ this.snapshotUnsub = null;
105586
+ await super.removeDevice();
105587
+ }
105588
+ getAccessoryChildren() {
105589
+ return accessoryChildSpecs({
105590
+ equipment: "box",
105591
+ specIds: this.specIds,
105592
+ integrationId: this.integrationId
105593
+ });
105594
+ }
103826
105595
  /** The box's local device page (plain HTTP, the page port); no credentials in the URL. */
103827
105596
  registerAdminLinkCap() {
103828
105597
  this.ctx.registerNativeCap(deviceAdminLinkCapability, { getAdminLink: async ({ deviceId }) => {
@@ -103878,10 +105647,11 @@ var ENPAL_MIGRATIONS_DATE = "2026-11-05";
103878
105647
  /**
103879
105648
  * Enpal solar box provider, `mode: account` and broker-less. An integration is one box: its
103880
105649
  * host, InfluxDB access and token live on the integration's settings, and the addon polls it
103881
- * with one manager per `integrationId`. Discovery offers the box itself
103882
- * ({@link DeviceType.Gateway}, with a link to its device page) and one
103883
- * {@link DeviceType.Container} per piece of equipment it reports (photovoltaic, battery,
103884
- * grid, house, heat pump), and adopting a container adopts its Sensor children.
105650
+ * with one manager per `integrationId`. Discovery offers three pieces, each adopted only when
105651
+ * the operator picks it: the box itself ("Enpal Box", a {@link DeviceType.Gateway} carrying its
105652
+ * firmware, its diagnostics and a link to its device page), the "System" (PV, battery, grid,
105653
+ * house meter) and the "Heat pump (Enpal)", both {@link DeviceType.Container}s. Adopting a
105654
+ * piece adopts its Sensor children; a Container declares which of them stands for it.
103885
105655
  *
103886
105656
  * The box does not advertise itself on the network, so the integration is added by hand.
103887
105657
  */
@@ -103895,6 +105665,8 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
103895
105665
  integrationReconciler = null;
103896
105666
  clients = null;
103897
105667
  instances = null;
105668
+ /** The Python bridge to the box's Blazor channel; null while the node has no embedded Python. */
105669
+ blazorReader = null;
103898
105670
  constructor() {
103899
105671
  super({});
103900
105672
  }
@@ -103917,7 +105689,9 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
103917
105689
  kind: enpalConnectionsEntryKind
103918
105690
  }));
103919
105691
  this.instances = buildEnpalInstances(records, { onChanged: () => this.reconcileIntegrations() });
105692
+ this.blazorReader = await this.createBlazorBridge();
103920
105693
  this.clients = new EnpalClientRegistry(this.ctx.logger, {
105694
+ blazor: () => this.blazorReader ?? void 0,
103921
105695
  onConnected: (integrationId) => this.setIntegrationDevicesOnline(integrationId, true),
103922
105696
  onDisconnected: (integrationId) => this.setIntegrationDevicesOnline(integrationId, false)
103923
105697
  });
@@ -103942,11 +105716,32 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
103942
105716
  this.ctx.logger.warn("Enpal shutdown error", { meta: { error: errMsg(err) } });
103943
105717
  }
103944
105718
  this.clients = null;
105719
+ this.blazorReader = null;
103945
105720
  this.integrationReconciler?.dispose();
103946
105721
  this.integrationReconciler = null;
103947
105722
  enpalConnections.clear();
103948
105723
  await super.onShutdown();
103949
105724
  }
105725
+ /**
105726
+ * The embedded Python and the bundled worker, or null with a logged reason: the addon then
105727
+ * reads InfluxDB or the device page, which on firmware 8.51 and later hold less.
105728
+ */
105729
+ async createBlazorBridge() {
105730
+ try {
105731
+ const pythonPath = await this.ctx.deps.ensurePython();
105732
+ if (pythonPath === null) {
105733
+ this.ctx.logger.warn("Enpal: embedded Python is unavailable, the Blazor channel is not used");
105734
+ return null;
105735
+ }
105736
+ return createBlazorReader({
105737
+ pythonPath,
105738
+ scriptPath: resolveEnpalWorker()
105739
+ });
105740
+ } catch (err) {
105741
+ this.ctx.logger.warn("Enpal: the Python bridge could not be prepared", { meta: { error: errMsg(err) } });
105742
+ return null;
105743
+ }
105744
+ }
103950
105745
  requireInstances() {
103951
105746
  if (!this.instances) throw new Error("Enpal provider not initialised");
103952
105747
  return this.instances;
@@ -104050,7 +105845,7 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
104050
105845
  }
104051
105846
  return out;
104052
105847
  },
104053
- adoptDevice: async ({ equipment, specIds, integrationId, name }) => {
105848
+ adoptDevice: async ({ equipment, specIds, integrationId, name, childLayout, primaryChildPreference }) => {
104054
105849
  const devices = this.ctx.kernel.devices;
104055
105850
  if (!devices) throw new Error("enpal adopt: kernel.devices unavailable");
104056
105851
  const config = {
@@ -104063,7 +105858,9 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
104063
105858
  const device = await devices.create(stableId, EnpalEquipmentDevice, config, null, {
104064
105859
  type: DeviceType.Container,
104065
105860
  name,
104066
- integrationId
105861
+ integrationId,
105862
+ ...childLayout.length > 0 ? { childLayout } : {},
105863
+ ...primaryChildPreference.length > 0 ? { primaryChildPreference } : {}
104067
105864
  });
104068
105865
  const children = await devices.getChildren(device.id);
104069
105866
  return {
@@ -104071,20 +105868,27 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
104071
105868
  accessoryDeviceIds: children.map((c) => c.id)
104072
105869
  };
104073
105870
  },
104074
- adoptGateway: async ({ integrationId, name }) => {
105871
+ adoptGateway: async ({ integrationId, name, specIds, childLayout }) => {
104075
105872
  const devices = this.ctx.kernel.devices;
104076
105873
  if (!devices) throw new Error("enpal adopt: kernel.devices unavailable");
104077
105874
  const config = {
104078
105875
  equipment: "box",
105876
+ specIds,
104079
105877
  integrationId,
104080
105878
  system: ENPAL_SYSTEM
104081
105879
  };
104082
105880
  const stableId = this.generateStableId(DeviceType.Gateway, config);
104083
- return { deviceId: (await devices.create(stableId, EnpalGatewayDevice, config, null, {
105881
+ const device = await devices.create(stableId, EnpalGatewayDevice, config, null, {
104084
105882
  type: DeviceType.Gateway,
104085
105883
  name,
104086
- integrationId
104087
- })).id };
105884
+ integrationId,
105885
+ ...childLayout.length > 0 ? { childLayout } : {}
105886
+ });
105887
+ const children = await devices.getChildren(device.id);
105888
+ return {
105889
+ deviceId: device.id,
105890
+ accessoryDeviceIds: children.map((c) => c.id)
105891
+ };
104088
105892
  },
104089
105893
  removeDevice: async (id) => {
104090
105894
  await this.ctx.kernel.devices?.remove(id);