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