@camstack/addon-provider-homematic 1.2.163 → 1.2.165

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
@@ -5465,7 +5465,7 @@ var ZodIssueCode = {
5465
5465
  var ZodFirstPartyTypeKind;
5466
5466
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5467
5467
  //#endregion
5468
- //#region ../types/dist/sleep-63gM_1JR.mjs
5468
+ //#region ../types/dist/sleep-CeyhrcOk.mjs
5469
5469
  /**
5470
5470
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5471
5471
  * window to float samples (D455).
@@ -7532,6 +7532,7 @@ var BaseAddon = class {
7532
7532
  * a warning and falls back rather than crashing boot.
7533
7533
  */
7534
7534
  state(key, schema, fallback) {
7535
+ this._declaredStateKeys.add(key);
7535
7536
  return createDurableState({
7536
7537
  key,
7537
7538
  schema,
@@ -7547,6 +7548,96 @@ var BaseAddon = class {
7547
7548
  } })
7548
7549
  });
7549
7550
  }
7551
+ /** Durable-state keys this process registered through {@link state}. */
7552
+ _declaredStateKeys = /* @__PURE__ */ new Set();
7553
+ /**
7554
+ * Stale-data audit: every global-settings key this addon still declares —
7555
+ * constructor defaults, settings-schema fields (layout containers included),
7556
+ * durable-state keys and the root of each `secretStatePaths` entry. Answered
7557
+ * here, in the addon's own process, so the hub keeps no copy of it.
7558
+ */
7559
+ async getDeclaredSettingsKeys() {
7560
+ return { keys: [...this.declaredKeySet()].toSorted() };
7561
+ }
7562
+ declaredKeySet() {
7563
+ const keys = new Set(Object.keys(this.defaults));
7564
+ const schema = this.globalSettingsSchema();
7565
+ if (schema) for (const section of schema.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
7566
+ for (const key of this._declaredStateKeys) keys.add(key);
7567
+ for (const path of this.secretStatePaths) {
7568
+ const root = path.split(/[.[]/, 1)[0];
7569
+ if (root) keys.add(root);
7570
+ }
7571
+ return keys;
7572
+ }
7573
+ /**
7574
+ * Stale-data audit: remove stored global-settings keys the addon no longer
7575
+ * declares, and clear the keychain values their markers held — except a value
7576
+ * a surviving key still points at. A declared or absent key is refused.
7577
+ */
7578
+ async unsetStoredSettingsKeys(keys) {
7579
+ const settings = this._ctx?.settings;
7580
+ const raw = this.rawSettings();
7581
+ if (!settings || !raw) return {
7582
+ removed: [],
7583
+ refused: keys.map((key) => ({
7584
+ key,
7585
+ reason: "no settings store"
7586
+ })),
7587
+ secretsCleared: 0
7588
+ };
7589
+ let stored;
7590
+ try {
7591
+ stored = await raw.readAddonStore();
7592
+ } catch (error) {
7593
+ const reason = `store unreadable: ${error instanceof Error ? error.message : "unknown"}`;
7594
+ return {
7595
+ removed: [],
7596
+ refused: keys.map((key) => ({
7597
+ key,
7598
+ reason
7599
+ })),
7600
+ secretsCleared: 0
7601
+ };
7602
+ }
7603
+ const declared = this.declaredKeySet();
7604
+ const removed = [];
7605
+ const refused = [];
7606
+ for (const key of keys) {
7607
+ const base = key.split("@", 1)[0] ?? key;
7608
+ if (declared.has(base)) refused.push({
7609
+ key,
7610
+ reason: "still declared by the addon"
7611
+ });
7612
+ else if (!(key in stored)) refused.push({
7613
+ key,
7614
+ reason: "not stored"
7615
+ });
7616
+ else removed.push(key);
7617
+ }
7618
+ if (removed.length === 0) return {
7619
+ removed,
7620
+ refused,
7621
+ secretsCleared: 0
7622
+ };
7623
+ const kept = /* @__PURE__ */ new Set();
7624
+ const held = /* @__PURE__ */ new Set();
7625
+ for (const [key, value] of Object.entries(stored)) {
7626
+ const target = removed.includes(key) ? held : kept;
7627
+ for (const ref of collectSecretMarkerRefs(value)) target.add(ref);
7628
+ }
7629
+ const refsToClear = [...held].filter((ref) => !kept.has(ref));
7630
+ const patch = {};
7631
+ for (const key of removed) patch[key] = void 0;
7632
+ await settings.writeAddonStore(patch);
7633
+ const secretsCleared = refsToClear.length > 0 && this._ctx?.secrets ? await this._ctx.secrets.delete(refsToClear) : 0;
7634
+ await this.resolveConfig();
7635
+ return {
7636
+ removed,
7637
+ refused,
7638
+ secretsCleared
7639
+ };
7640
+ }
7550
7641
  /** Per-device variant of {@link state}, backed by the per-device store. */
7551
7642
  deviceState(deviceId, key, schema, fallback) {
7552
7643
  return createDurableState({
@@ -7817,6 +7908,21 @@ function dropPerNodeFields(fields) {
7817
7908
  }
7818
7909
  return kept;
7819
7910
  }
7911
+ function collectFieldKeys(fields) {
7912
+ const collected = [];
7913
+ for (const field of fields) {
7914
+ if (field.type === "group") {
7915
+ collected.push(...collectFieldKeys(field.fields));
7916
+ continue;
7917
+ }
7918
+ if (field.type === "sub-tabs") {
7919
+ for (const tab of field.tabs) collected.push(...collectFieldKeys(tab.fields));
7920
+ continue;
7921
+ }
7922
+ if ("key" in field && typeof field.key === "string") collected.push(field.key);
7923
+ }
7924
+ return collected;
7925
+ }
7820
7926
  function collectPerNodeFieldKeys(fields) {
7821
7927
  const collected = [];
7822
7928
  for (const field of fields) {
@@ -16166,8 +16272,172 @@ object({
16166
16272
  quantity: ReadingQuantitySchema,
16167
16273
  unit: string().min(1)
16168
16274
  });
16169
- /** How a leaf compares a device field to a value. Derived from the field's
16170
- * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
16275
+ new Set([
16276
+ {
16277
+ id: "person",
16278
+ name: "Person"
16279
+ },
16280
+ {
16281
+ id: "vehicle",
16282
+ name: "Vehicle"
16283
+ },
16284
+ {
16285
+ id: "animal",
16286
+ name: "Animal"
16287
+ },
16288
+ {
16289
+ id: "package",
16290
+ name: "Package"
16291
+ }
16292
+ ].map((l) => l.id));
16293
+ var COCO_TO_MACRO = {
16294
+ mapping: {
16295
+ person: "person",
16296
+ bicycle: "vehicle",
16297
+ car: "vehicle",
16298
+ motorcycle: "vehicle",
16299
+ airplane: "vehicle",
16300
+ bus: "vehicle",
16301
+ train: "vehicle",
16302
+ truck: "vehicle",
16303
+ boat: "vehicle",
16304
+ bird: "animal",
16305
+ cat: "animal",
16306
+ dog: "animal",
16307
+ horse: "animal",
16308
+ sheep: "animal",
16309
+ cow: "animal",
16310
+ elephant: "animal",
16311
+ bear: "animal",
16312
+ zebra: "animal",
16313
+ giraffe: "animal",
16314
+ suitcase: "package",
16315
+ backpack: "package",
16316
+ handbag: "package"
16317
+ },
16318
+ preserveOriginal: false
16319
+ };
16320
+ /**
16321
+ * WHAT a detection-shaped leaf is about: one MACRO class (person, vehicle,
16322
+ * animal, package) plus an OPTIONAL label that narrows it.
16323
+ *
16324
+ * The taxonomy is `COCO_TO_MACRO` (and `CLASS_MAP_MACRO_TARGETS`) — never a
16325
+ * second list. The label that fits depends on the macro class:
16326
+ *
16327
+ * - `animal` → a sub-class (`dog`, `cat`, …);
16328
+ * - `person` → a known face (an identity of the `face-gallery`);
16329
+ * - `vehicle` → a plate (its text) or a sub-class (`car`, `truck`, …);
16330
+ * - `package` → a sub-class.
16331
+ *
16332
+ * `other` exists only so a class the taxonomy does not know (a custom model's
16333
+ * `forklift`) survives the conversion of a stored recipe without widening.
16334
+ *
16335
+ * A label is decided LATE (D683): face recognition and LPR land after the
16336
+ * detection starts. The track-lifecycle `update` event carries the newly
16337
+ * resolved `label` / `plateText`, which is why a leaf with a label fires when
16338
+ * the subject GETS it — not at the first detection.
16339
+ */
16340
+ var SubClassLabelSchema = object({
16341
+ kind: literal("sub-class"),
16342
+ className: string().min(1)
16343
+ });
16344
+ var IdentityLabelSchema = object({
16345
+ kind: literal("identity"),
16346
+ /** The `face-gallery` identity id (stable across a rename). */
16347
+ identityId: string().min(1),
16348
+ /** The identity's name — what a track-lifecycle event carries as `label`. */
16349
+ name: string().min(1)
16350
+ });
16351
+ var PlateLabelSchema = object({
16352
+ kind: literal("plate"),
16353
+ text: string().min(1)
16354
+ });
16355
+ function macroOf$1(className) {
16356
+ return COCO_TO_MACRO.mapping[className.toLowerCase()] ?? className.toLowerCase();
16357
+ }
16358
+ /** A sub-class label must belong to the macro it is under. */
16359
+ function subClassBelongs(macro, label) {
16360
+ return label === void 0 || macroOf$1(label.className) === macro;
16361
+ }
16362
+ var subClassOf = (macro) => SubClassLabelSchema.refine((l) => subClassBelongs(macro, l), { message: `that class is not a kind of ${macro} in the detection taxonomy` });
16363
+ var DetectionSubjectSchema = discriminatedUnion("macro", [
16364
+ object({
16365
+ macro: literal("animal"),
16366
+ label: subClassOf("animal").optional()
16367
+ }),
16368
+ object({
16369
+ macro: literal("person"),
16370
+ label: IdentityLabelSchema.optional()
16371
+ }),
16372
+ object({
16373
+ macro: literal("vehicle"),
16374
+ label: discriminatedUnion("kind", [SubClassLabelSchema.refine((l) => subClassBelongs("vehicle", l), { message: "that class is not a kind of vehicle in the detection taxonomy" }), PlateLabelSchema]).optional()
16375
+ }),
16376
+ object({
16377
+ macro: literal("package"),
16378
+ label: subClassOf("package").optional()
16379
+ }),
16380
+ object({
16381
+ macro: literal("other"),
16382
+ className: string().min(1)
16383
+ })
16384
+ ]);
16385
+ /**
16386
+ * The subject of a PARKED object. The stationary registry's event carries a
16387
+ * class and a zone, no face and no plate — an identity or plate label there
16388
+ * could never match, so the schema refuses it instead of letting it sit dead.
16389
+ */
16390
+ var StationarySubjectSchema = DetectionSubjectSchema.refine((s) => s.macro === "other" || s.label === void 0 || s.label.kind === "sub-class", { message: "a parked object carries no face or plate — only a class can narrow it" });
16391
+ /**
16392
+ * A stored class string as a subject: a macro name stays a macro; a raw class
16393
+ * the taxonomy files under a macro becomes that macro plus the sub-class label
16394
+ * (`dog` → animal / dog); anything else is `other`, kept verbatim.
16395
+ */
16396
+ function subjectFromClassName(className) {
16397
+ const name = className.trim().toLowerCase();
16398
+ const macro = COCO_TO_MACRO.mapping[name];
16399
+ if (new Set(Object.values(COCO_TO_MACRO.mapping)).has(name)) return macroOnly(name);
16400
+ if (macro === void 0) return {
16401
+ macro: "other",
16402
+ className
16403
+ };
16404
+ const label = {
16405
+ kind: "sub-class",
16406
+ className: name
16407
+ };
16408
+ switch (macro) {
16409
+ case "animal": return {
16410
+ macro: "animal",
16411
+ label
16412
+ };
16413
+ case "vehicle": return {
16414
+ macro: "vehicle",
16415
+ label
16416
+ };
16417
+ case "package": return {
16418
+ macro: "package",
16419
+ label
16420
+ };
16421
+ default: return {
16422
+ macro: "other",
16423
+ className
16424
+ };
16425
+ }
16426
+ }
16427
+ function macroOnly(macro) {
16428
+ switch (macro) {
16429
+ case "animal": return { macro: "animal" };
16430
+ case "person": return { macro: "person" };
16431
+ case "vehicle": return { macro: "vehicle" };
16432
+ case "package": return { macro: "package" };
16433
+ default: return {
16434
+ macro: "other",
16435
+ className: macro
16436
+ };
16437
+ }
16438
+ }
16439
+ /** How a state leaf compares a device field to a value. Derived from the
16440
+ * field's `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
16171
16441
  var AutomationConditionOperatorSchema = _enum([
16172
16442
  "eq",
16173
16443
  "ne",
@@ -16178,12 +16448,76 @@ var AutomationConditionOperatorSchema = _enum([
16178
16448
  "contains",
16179
16449
  "in"
16180
16450
  ]);
16181
- var AutomationConditionLeafSchema = object({
16182
- kind: literal("condition"),
16451
+ /**
16452
+ * D8 compliance, and it is the reason a "becomes" leaf is not merely an event
16453
+ * subscription: it evaluates against the **state mirror**, which is reconciled,
16454
+ * and an event only WAKES the evaluation. A dropped event therefore DELAYS a
16455
+ * fire; it does not lose it.
16456
+ */
16457
+ var DeviceBecomesLeafSchema = object({
16458
+ kind: literal("device-becomes"),
16459
+ deviceId: number().int().nonnegative(),
16460
+ cap: string().min(1),
16461
+ fieldPath: string().min(1),
16462
+ /** Fire when the field takes this value. Omit to fire on any change. */
16463
+ becomes: union([
16464
+ string(),
16465
+ number(),
16466
+ boolean()
16467
+ ]).optional(),
16468
+ /** Only on a CHANGE of value, not on every re-report. */
16469
+ edge: boolean().optional(),
16470
+ /** The new value must hold this long before it counts. */
16471
+ forMs: number().int().min(0).max(864e5).optional(),
16472
+ /** Collapse a burst into one occurrence. */
16473
+ debounceMs: number().int().min(0).max(6e5).optional()
16474
+ });
16475
+ var DeviceEventLeafSchema = object({
16476
+ kind: literal("device-event"),
16477
+ /** An `EventCategory` value. */
16478
+ category: string().min(1),
16479
+ deviceId: number().int().nonnegative().optional()
16480
+ });
16481
+ var ScheduleLeafSchema = object({
16482
+ kind: literal("schedule"),
16483
+ cron: string().min(1).max(120)
16484
+ });
16485
+ var ManualLeafSchema = object({ kind: literal("manual") });
16486
+ var IntervalLeafSchema = object({
16487
+ kind: literal("interval"),
16488
+ minutes: number().int().min(1).max(1440)
16489
+ });
16490
+ var DetectionLeafSchema = object({
16491
+ kind: literal("detection"),
16492
+ /** One macro class each, optionally narrowed by a label; a track of any listed subject fires. */
16493
+ subjects: array(DetectionSubjectSchema).min(1),
16494
+ deviceIds: array(number().int().nonnegative()).min(1),
16495
+ /** The track must have visited at least one of these zones. */
16496
+ zones: array(string().min(1)).min(1).optional(),
16497
+ /** Best-so-far confidence the track must reach. */
16498
+ minScore: number().min(0).max(1).optional()
16499
+ });
16500
+ var StationaryLeafSchema = object({
16501
+ kind: literal("stationary"),
16502
+ deviceIds: array(number().int().nonnegative()).min(1),
16503
+ subjects: array(StationarySubjectSchema).min(1).optional(),
16504
+ zones: array(string().min(1)).min(1).optional(),
16505
+ /** `appeared`: it became parked. `departed`: the registry retired it. */
16506
+ phase: _enum(["appeared", "departed"])
16507
+ });
16508
+ /** "is": one device field compared to one value, NOW. */
16509
+ var DeviceIsLeafSchema = object({
16510
+ kind: literal("device-is"),
16183
16511
  deviceId: number().int().nonnegative(),
16184
16512
  cap: string().min(1),
16185
16513
  fieldPath: string().min(1),
16186
16514
  operator: AutomationConditionOperatorSchema,
16515
+ /**
16516
+ * The value. A number on a quantity field is stored in the field's base unit
16517
+ * (D944); a quantity value is the WRITE form, which the save boundary
16518
+ * replaces with the converted number and `unit`. A leaf that still carries
16519
+ * one is never evaluated (it fails).
16520
+ */
16187
16521
  value: union([
16188
16522
  string(),
16189
16523
  number(),
@@ -16191,24 +16525,26 @@ var AutomationConditionLeafSchema = object({
16191
16525
  array(union([string(), number()])),
16192
16526
  QuantityValueSchema
16193
16527
  ]),
16528
+ /** The unit the person picked for `value` (display only; `value` is in the base unit). */
16194
16529
  unit: string().min(1).optional()
16195
16530
  });
16196
- var AutomationConditionStationaryPresentSchema = object({
16531
+ /**
16532
+ * True when the camera currently has a PARKED object matching `subjects` in at
16533
+ * least one of `zones`. Reads the zone-analytics snapshot through the same
16534
+ * mirror as every other state leaf, so a dropped event never decides it (D49).
16535
+ */
16536
+ var StationaryPresentLeafSchema = object({
16197
16537
  kind: literal("stationary-present"),
16198
16538
  deviceId: number().int().nonnegative(),
16199
- /** Class names; a macro class matches every raw class that maps to it. */
16200
- classes: array(string().min(1)).min(1).optional(),
16201
- /** The parked object must be in at least one of these zones. */
16539
+ subjects: array(StationarySubjectSchema).min(1).optional(),
16202
16540
  zones: array(string().min(1)).min(1).optional()
16203
16541
  });
16204
16542
  /**
16205
- * The expression leaf, declared as a plain object rather than an intersection
16206
- * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
16207
- * read `kind` off each option, and an intersection hides it. The author-time
16208
- * validation is the SAME function `ExpressionSourceSchema` runs, so the two
16209
- * cannot drift — an expression that one accepts, the other accepts.
16543
+ * The escape hatch: the bounded expression engine over named device bindings.
16544
+ * Declared as a plain object so a discriminated union can read `kind`; the
16545
+ * author-time validation is the SAME function `ExpressionSourceSchema` runs.
16210
16546
  */
16211
- var AutomationConditionExpressionSchema = object({
16547
+ var ExpressionLeafSchema = object({
16212
16548
  kind: literal("expression"),
16213
16549
  expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
16214
16550
  bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
@@ -16220,55 +16556,62 @@ var AutomationConditionExpressionSchema = object({
16220
16556
  path: ["expr"]
16221
16557
  });
16222
16558
  });
16223
- var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
16559
+ var AutomationWhenSchema = lazy(() => discriminatedUnion("kind", [
16224
16560
  object({
16225
16561
  kind: literal("all"),
16226
- children: array(AutomationConditionSchema)
16562
+ children: array(AutomationWhenSchema)
16227
16563
  }),
16228
16564
  object({
16229
16565
  kind: literal("any"),
16230
- children: array(AutomationConditionSchema)
16566
+ children: array(AutomationWhenSchema)
16231
16567
  }),
16232
16568
  object({
16233
16569
  kind: literal("not"),
16234
- child: AutomationConditionSchema
16570
+ child: AutomationWhenSchema
16235
16571
  }),
16236
- AutomationConditionLeafSchema,
16237
- AutomationConditionExpressionSchema,
16238
- AutomationConditionStationaryPresentSchema
16572
+ DeviceBecomesLeafSchema,
16573
+ DeviceEventLeafSchema,
16574
+ ScheduleLeafSchema,
16575
+ ManualLeafSchema,
16576
+ IntervalLeafSchema,
16577
+ DetectionLeafSchema,
16578
+ StationaryLeafSchema,
16579
+ DeviceIsLeafSchema,
16580
+ StationaryPresentLeafSchema,
16581
+ ExpressionLeafSchema
16239
16582
  ]));
16240
16583
  /**
16241
- * What starts a run.
16242
- *
16243
- * D8 compliance, and it is the reason `device-state` is not merely an event
16244
- * subscription: the trigger evaluates against the **state mirror**, which is
16245
- * reconciled, and an event only WAKES the evaluation. A dropped event therefore
16246
- * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
16247
- * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
16248
- * not.
16249
- */
16250
- var AutomationTriggerSchema = discriminatedUnion("kind", [
16584
+ * The recipe shape before the single `when` tree: a flat list of triggers
16585
+ * (OR'd, each an instant) and an optional condition tree. Stored recipes —
16586
+ * the `AUTOMATION_RECIPE` literal of every block saved so far, and whatever an
16587
+ * older client still posts — are in this shape, so it stays READABLE here, and
16588
+ * {@link convertLegacyRecipe} turns it into the new one, losslessly and one
16589
+ * way. Nothing writes this shape any more.
16590
+ *
16591
+ * `{ triggers: [t1…tn], conditions?: C }`
16592
+ * → `when = all(any(t1…tn), C)`, or `any(t1…tn)` without `C`;
16593
+ * a single trigger is not wrapped in `any`.
16594
+ * a `condition` leaf → a `device-is` leaf (the "is" verb);
16595
+ * a `device-state` trigger → a `device-becomes` leaf (the "becomes" verb);
16596
+ * `classes: ['dog']` → the macro `animal` with the label `dog`.
16597
+ */
16598
+ var LegacyTriggerSchema = discriminatedUnion("kind", [
16251
16599
  object({
16252
16600
  kind: literal("device-state"),
16253
16601
  deviceId: number().int().nonnegative(),
16254
16602
  cap: string().min(1),
16255
16603
  fieldPath: string().min(1),
16256
- /** Fire when the field takes this value. Omit to fire on any change. */
16257
16604
  becomes: union([
16258
16605
  string(),
16259
16606
  number(),
16260
16607
  boolean()
16261
16608
  ]).optional(),
16262
- /** Only on a CHANGE of value, not on every re-report. */
16263
16609
  edge: boolean().optional(),
16264
- /** The condition must hold this long before the run starts. */
16265
16610
  forMs: number().int().min(0).max(864e5).optional(),
16266
- /** Collapse a burst into one run. */
16267
16611
  debounceMs: number().int().min(0).max(6e5).optional()
16268
16612
  }),
16269
16613
  object({
16270
16614
  kind: literal("device-event"),
16271
- /** An `EventCategory` value. */
16272
16615
  category: string().min(1),
16273
16616
  deviceId: number().int().nonnegative().optional()
16274
16617
  }),
@@ -16279,25 +16622,16 @@ var AutomationTriggerSchema = discriminatedUnion("kind", [
16279
16622
  object({ kind: literal("manual") }),
16280
16623
  object({
16281
16624
  kind: literal("detection"),
16282
- /** Class names. A macro class (`animal`, `vehicle`, `person`, `package`)
16283
- * matches every raw class that maps to it; any other name matches
16284
- * exactly. */
16285
16625
  classes: array(string().min(1)).min(1),
16286
16626
  deviceIds: array(number().int().nonnegative()).min(1),
16287
- /** The track must have visited at least one of these zones. */
16288
16627
  zones: array(string().min(1)).min(1).optional(),
16289
- /** Best-so-far confidence the track must reach. */
16290
16628
  minScore: number().min(0).max(1).optional()
16291
16629
  }),
16292
16630
  object({
16293
16631
  kind: literal("stationary"),
16294
16632
  deviceIds: array(number().int().nonnegative()).min(1),
16295
- /** Class names; a macro class matches like the detection trigger. */
16296
16633
  classes: array(string().min(1)).min(1).optional(),
16297
- /** The parked object must be in at least one of these zones. */
16298
16634
  zones: array(string().min(1)).min(1).optional(),
16299
- /** `appeared`: it became parked. `departed`: the registry retired it.
16300
- * The vocabulary of `PipelineAnalyticsStationaryChanged`. */
16301
16635
  phase: _enum(["appeared", "departed"])
16302
16636
  }),
16303
16637
  object({
@@ -16305,6 +16639,143 @@ var AutomationTriggerSchema = discriminatedUnion("kind", [
16305
16639
  minutes: number().int().min(1).max(1440)
16306
16640
  })
16307
16641
  ]);
16642
+ var LegacyConditionLeafSchema = object({
16643
+ kind: literal("condition"),
16644
+ deviceId: number().int().nonnegative(),
16645
+ cap: string().min(1),
16646
+ fieldPath: string().min(1),
16647
+ operator: AutomationConditionOperatorSchema,
16648
+ value: union([
16649
+ string(),
16650
+ number(),
16651
+ boolean(),
16652
+ array(union([string(), number()])),
16653
+ QuantityValueSchema
16654
+ ]),
16655
+ unit: string().min(1).optional()
16656
+ });
16657
+ var LegacyStationaryPresentSchema = object({
16658
+ kind: literal("stationary-present"),
16659
+ deviceId: number().int().nonnegative(),
16660
+ classes: array(string().min(1)).min(1).optional(),
16661
+ zones: array(string().min(1)).min(1).optional()
16662
+ });
16663
+ var LegacyExpressionSchema = object({
16664
+ kind: literal("expression"),
16665
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
16666
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
16667
+ }).superRefine((src, ctx) => {
16668
+ const err = validateExpressionSource(src);
16669
+ if (err !== null) ctx.addIssue({
16670
+ code: "custom",
16671
+ message: err,
16672
+ path: ["expr"]
16673
+ });
16674
+ });
16675
+ var LegacyConditionSchema = lazy(() => discriminatedUnion("kind", [
16676
+ object({
16677
+ kind: literal("all"),
16678
+ children: array(LegacyConditionSchema)
16679
+ }),
16680
+ object({
16681
+ kind: literal("any"),
16682
+ children: array(LegacyConditionSchema)
16683
+ }),
16684
+ object({
16685
+ kind: literal("not"),
16686
+ child: LegacyConditionSchema
16687
+ }),
16688
+ LegacyConditionLeafSchema,
16689
+ LegacyExpressionSchema,
16690
+ LegacyStationaryPresentSchema
16691
+ ]));
16692
+ function convertTrigger(trigger) {
16693
+ switch (trigger.kind) {
16694
+ case "device-state": return {
16695
+ ...trigger,
16696
+ kind: "device-becomes"
16697
+ };
16698
+ case "detection": {
16699
+ const { classes, ...rest } = trigger;
16700
+ return {
16701
+ ...rest,
16702
+ subjects: classes.map(subjectFromClassName)
16703
+ };
16704
+ }
16705
+ case "stationary": {
16706
+ const { classes, ...rest } = trigger;
16707
+ return {
16708
+ ...rest,
16709
+ ...classes === void 0 ? {} : { subjects: classes.map(subjectFromClassName) }
16710
+ };
16711
+ }
16712
+ default: return trigger;
16713
+ }
16714
+ }
16715
+ /** An old condition tree as a `when` subtree: `condition` → "is", classes → subjects. */
16716
+ function convertLegacyCondition(condition) {
16717
+ switch (condition.kind) {
16718
+ case "condition": return {
16719
+ ...condition,
16720
+ kind: "device-is"
16721
+ };
16722
+ case "stationary-present": {
16723
+ const { classes, ...rest } = condition;
16724
+ return {
16725
+ ...rest,
16726
+ ...classes === void 0 ? {} : { subjects: classes.map(subjectFromClassName) }
16727
+ };
16728
+ }
16729
+ case "expression": return condition;
16730
+ case "not": return {
16731
+ kind: "not",
16732
+ child: convertLegacyCondition(condition.child)
16733
+ };
16734
+ case "all":
16735
+ case "any": return {
16736
+ kind: condition.kind,
16737
+ children: condition.children.map(convertLegacyCondition)
16738
+ };
16739
+ }
16740
+ }
16741
+ /** `all(any(t1…tn), C)`, or `any(t1…tn)`; a single trigger is not wrapped in `any`. */
16742
+ function convertLegacyWhen(half) {
16743
+ const leaves = half.triggers.map(convertTrigger);
16744
+ const first = leaves[0];
16745
+ const triggers = leaves.length === 1 && first !== void 0 ? first : {
16746
+ kind: "any",
16747
+ children: leaves
16748
+ };
16749
+ if (half.conditions === void 0) return triggers;
16750
+ return {
16751
+ kind: "all",
16752
+ children: [triggers, convertLegacyCondition(half.conditions)]
16753
+ };
16754
+ }
16755
+ /**
16756
+ * The automation RECIPE — what a visually-authored automation is, before it
16757
+ * becomes code.
16758
+ *
16759
+ * This is the data half of `docs/design/2026-08-08-automation-scripts-custom-devices.md`
16760
+ * Phase 6. A recipe is not an engine and never evaluates anything: it is a
16761
+ * declarative description that {@link generateAutomationBlock} compiles to the
16762
+ * TypeScript of an ordinary core block. There is therefore no second rule
16763
+ * engine, no second store and no second runtime — the block runner is the
16764
+ * runtime, `core_blocks` is the store (§2.1).
16765
+ *
16766
+ * Two parts:
16767
+ *
16768
+ * - **`when`** — ONE tree (`./when.ts`) of all/any/not over event leaves
16769
+ * (instants) and state leaves (durations). It replaces the flat trigger list
16770
+ * plus the separate condition tree; the firing rule is in that file and in
16771
+ * ADR "one tree".
16772
+ * - **Actions** — `NcRuleActionSchema`'s two steps, plus the `{kind:'code'}`
16773
+ * fragment of §3.2.2. See {@link AutomationActionSchema} for the ONE place
16774
+ * this had to diverge from "verbatim", and why.
16775
+ *
16776
+ * A recipe stored in the old shape (`triggers` + `conditions`) still LOADS:
16777
+ * {@link AutomationRecipeSchema} (the parse-anywhere schema) converts it on parse. Nothing writes it.
16778
+ */
16308
16779
  /**
16309
16780
  * One action step.
16310
16781
  *
@@ -16407,69 +16878,49 @@ var AutomationRunModeSchema = discriminatedUnion("mode", [
16407
16878
  minutes: number().int().min(1).max(1440)
16408
16879
  })
16409
16880
  ]);
16410
- var AutomationRecipeSchema = object({
16411
- triggers: array(AutomationTriggerSchema),
16412
- conditions: AutomationConditionSchema.optional(),
16881
+ var CurrentAutomationRecipeSchema = object({
16882
+ when: AutomationWhenSchema,
16413
16883
  actions: array(AutomationActionSchema),
16414
16884
  runMode: AutomationRunModeSchema.optional(),
16415
16885
  /** How many run traces the automation keeps (default {@link DEFAULT_TRACE_KEEP}). */
16416
16886
  keepTraces: number().int().min(1).max(100).optional()
16417
16887
  });
16418
- new Set([
16419
- {
16420
- id: "person",
16421
- name: "Person"
16422
- },
16423
- {
16424
- id: "vehicle",
16425
- name: "Vehicle"
16426
- },
16427
- {
16428
- id: "animal",
16429
- name: "Animal"
16430
- },
16431
- {
16432
- id: "package",
16433
- name: "Package"
16434
- }
16435
- ].map((l) => l.id));
16436
- var COCO_TO_MACRO = {
16437
- mapping: {
16438
- person: "person",
16439
- bicycle: "vehicle",
16440
- car: "vehicle",
16441
- motorcycle: "vehicle",
16442
- airplane: "vehicle",
16443
- bus: "vehicle",
16444
- train: "vehicle",
16445
- truck: "vehicle",
16446
- boat: "vehicle",
16447
- bird: "animal",
16448
- cat: "animal",
16449
- dog: "animal",
16450
- horse: "animal",
16451
- sheep: "animal",
16452
- cow: "animal",
16453
- elephant: "animal",
16454
- bear: "animal",
16455
- zebra: "animal",
16456
- giraffe: "animal",
16457
- suitcase: "package",
16458
- backpack: "package",
16459
- handbag: "package"
16460
- },
16461
- preserveOriginal: false
16462
- };
16888
+ /** A recipe as it was stored before the single tree. Read-only: nothing writes this. */
16889
+ var LegacyAutomationRecipeSchema = object({
16890
+ triggers: array(LegacyTriggerSchema),
16891
+ conditions: LegacyConditionSchema.optional(),
16892
+ actions: array(AutomationActionSchema),
16893
+ runMode: AutomationRunModeSchema.optional(),
16894
+ keepTraces: number().int().min(1).max(100).optional()
16895
+ });
16896
+ /** The old shape as the new one, lossless. */
16897
+ function convertLegacyRecipe(legacy) {
16898
+ const { triggers, conditions, ...rest } = legacy;
16899
+ return {
16900
+ when: convertLegacyWhen({
16901
+ triggers,
16902
+ conditions
16903
+ }),
16904
+ ...rest
16905
+ };
16906
+ }
16907
+ /**
16908
+ * Where recipes are PARSED (the core-blocks cap, the MCP tool, the literal a
16909
+ * saved block carries): the new shape as it is, or the old shape converted to
16910
+ * it. The output type is always the new one.
16911
+ */
16912
+ var AutomationRecipeSchema = union([CurrentAutomationRecipeSchema, LegacyAutomationRecipeSchema.transform(convertLegacyRecipe)]);
16463
16913
  new Set(Object.values(COCO_TO_MACRO.mapping));
16464
16914
  /**
16465
16915
  * Run traces — what an automation (or, later, an Action run) DID, node by node,
16466
16916
  * so the editor can paint every block green or red with its reason.
16467
16917
  *
16468
- * A trace is data, not a log: it covers the WHOLE tree — the trigger stage,
16469
- * every condition node (containers and leaves, including the ones a
16470
- * short-circuit never reached), the run-mode verdict and every action step —
16471
- * each addressed by a stable path (`conditions.0.1`, `actions.2`, or `steps.N`
16472
- * for an Action run). Skipped
16918
+ * A trace is data, not a log: it covers the WHOLE tree — the event leaf that
16919
+ * fired, every node of the `when` tree (containers and leaves, including the
16920
+ * ones a short-circuit never reached), the run-mode verdict and every action
16921
+ * step — each addressed by a stable path (`when.0.1`, `actions.2`, or `steps.N`
16922
+ * for an Action run). Traces written before the single tree carry
16923
+ * `conditions.*` paths and still parse. Skipped
16473
16924
  * evaluations are traced too, and identical consecutive skips collapse into a
16474
16925
  * repeat count so a trigger that fails every 5 minutes cannot flush real runs.
16475
16926
  *
@@ -16501,6 +16952,8 @@ var TraceNodeOutcomeSchema = _enum([
16501
16952
  ]);
16502
16953
  var AutomationTriggerTraceSchema = object({
16503
16954
  kind: string().min(1),
16955
+ /** The event leaf that fired (`when`, `when.0.1`); absent on traces written before the single tree. */
16956
+ path: string().min(1).optional(),
16504
16957
  outcome: _enum(["pass", "fail"]),
16505
16958
  /** Why an event was rejected ("class person not in [dog]") or what fired. */
16506
16959
  reason: string(),
@@ -16508,15 +16961,23 @@ var AutomationTriggerTraceSchema = object({
16508
16961
  vars: record(string(), TraceValueSchema)
16509
16962
  });
16510
16963
  var AutomationConditionTraceNodeSchema = object({
16511
- /** `conditions` for the root, `conditions.<i>` for its i-th child, … */
16964
+ /** `when` for the root, `when.<i>` for its i-th child, … (`conditions…` on an old trace). */
16512
16965
  path: string().min(1),
16513
16966
  kind: _enum([
16514
16967
  "all",
16515
16968
  "any",
16516
16969
  "not",
16517
16970
  "condition",
16971
+ "device-is",
16518
16972
  "expression",
16519
- "stationary-present"
16973
+ "stationary-present",
16974
+ "device-becomes",
16975
+ "device-event",
16976
+ "schedule",
16977
+ "interval",
16978
+ "manual",
16979
+ "detection",
16980
+ "stationary"
16520
16981
  ]),
16521
16982
  outcome: TraceNodeOutcomeSchema,
16522
16983
  reason: string(),
@@ -16550,6 +17011,12 @@ var AutomationStepTraceSchema = object({
16550
17011
  deviceId: number().int().optional(),
16551
17012
  startedAt: number().optional(),
16552
17013
  endedAt: number().optional(),
17014
+ /**
17015
+ * The parameters the step SENT, after `{{vars.x}}` substitution — what the
17016
+ * device was actually asked. Bounded like trigger `vars`; a secret parameter
17017
+ * is shown as {@link REDACTED_ARG}. Absent on traces written before it existed.
17018
+ */
17019
+ args: record(string(), TraceValueSchema).optional(),
16553
17020
  outcome: _enum([
16554
17021
  "ok",
16555
17022
  "error",
@@ -17299,6 +17766,23 @@ method(object({
17299
17766
  addonId: string(),
17300
17767
  nodeId: string().optional()
17301
17768
  }), SettingsSchemaWithValuesSchema.nullable()), method(object({
17769
+ addonId: string(),
17770
+ nodeId: string().optional()
17771
+ }), object({ keys: array(string()) }).nullable(), { auth: "admin" }), method(object({
17772
+ addonId: string(),
17773
+ nodeId: string().optional(),
17774
+ keys: array(string())
17775
+ }), object({
17776
+ removed: array(string()),
17777
+ refused: array(object({
17778
+ key: string(),
17779
+ reason: string()
17780
+ })),
17781
+ secretsCleared: number()
17782
+ }), {
17783
+ kind: "mutation",
17784
+ auth: "admin"
17785
+ }), method(object({
17302
17786
  addonId: string(),
17303
17787
  deviceId: number(),
17304
17788
  nodeId: string().optional()
@@ -21328,6 +21812,8 @@ var StorageKindSchema = object({
21328
21812
  "orphaned",
21329
21813
  "dropped"
21330
21814
  ]),
21815
+ /** An owner declared this kind since the engine started. */
21816
+ declaredThisBoot: boolean(),
21331
21817
  /** `null` when the caller asked for no counts (`counts: false`). */
21332
21818
  rows: number().int().nonnegative().nullable(),
21333
21819
  /** Rows x the average size of a sample; null for an empty kind. */
@@ -21339,6 +21825,51 @@ var StorageKindSchema = object({
21339
21825
  /** The whole declaration, secret paths and entry descriptor included (the entries service builds its handle from it). */
21340
21826
  declaration: RecordKindDeclarationSchema
21341
21827
  });
21828
+ _enum([
21829
+ "orphaned",
21830
+ "stale",
21831
+ "unknown"
21832
+ ]);
21833
+ /** A record kind nobody declared since its owner started, or whose owner is gone. */
21834
+ var StaleKindFindingSchema = object({
21835
+ owner: string(),
21836
+ kind: string(),
21837
+ /** `unknown`: the owner could not be judged (not running, or its state unreadable) - never dropped. */
21838
+ verdict: _enum(["orphaned", "unknown"]),
21839
+ reason: string(),
21840
+ rows: number().int().nonnegative().nullable(),
21841
+ /** local-auth's tables are never dropped by this audit. */
21842
+ protected: boolean()
21843
+ });
21844
+ /** A stored global-settings key the running addon no longer declares. */
21845
+ var StaleSettingsKeyFindingSchema = object({
21846
+ addonId: string(),
21847
+ /** The stored key as written, a per-node `<key>@<nodeId>` suffix included. */
21848
+ key: string(),
21849
+ /**
21850
+ * Keychain refs whose ONLY marker sits under this key: in use by stale data,
21851
+ * not orphans yet. Removing the key releases them.
21852
+ */
21853
+ secretRefs: array(string())
21854
+ });
21855
+ /** An addon the audit could not judge, with the reason. Never reported as stale. */
21856
+ var StaleUnknownSchema = object({
21857
+ subject: string(),
21858
+ reason: string()
21859
+ });
21860
+ var StaleDataReportSchema = object({
21861
+ /** Epoch ms of the last finished scan; `null` before the first. */
21862
+ scannedAt: number().nullable(),
21863
+ scanning: boolean(),
21864
+ trigger: _enum([
21865
+ "boot",
21866
+ "interval",
21867
+ "manual"
21868
+ ]).nullable(),
21869
+ kinds: array(StaleKindFindingSchema),
21870
+ settingsKeys: array(StaleSettingsKeyFindingSchema),
21871
+ unknown: array(StaleUnknownSchema)
21872
+ });
21342
21873
  method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: array(StorageTableSchema) }), { auth: "admin" }), method(object({
21343
21874
  owner: string().min(1).optional(),
21344
21875
  /** `false` skips the per-kind row count (a catalog read only). Default true. */
@@ -21346,7 +21877,30 @@ method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: arr
21346
21877
  }), object({ kinds: array(StorageKindSchema) }), { auth: "admin" }), method(RecordQueryInputSchema.extend({
21347
21878
  owner: string().min(1),
21348
21879
  kind: RecordKindNameSchema
21349
- }), RecordQueryResultSchema, { auth: "admin" });
21880
+ }), RecordQueryResultSchema, { auth: "admin" }), method(object({}), StaleDataReportSchema, { auth: "admin" }), method(object({}), StaleDataReportSchema, {
21881
+ kind: "mutation",
21882
+ auth: "admin"
21883
+ }), method(object({
21884
+ owner: string().min(1),
21885
+ kind: RecordKindNameSchema
21886
+ }), object({
21887
+ deleted: number().int().nonnegative(),
21888
+ secretsReleased: number().int().nonnegative()
21889
+ }), {
21890
+ kind: "mutation",
21891
+ auth: "admin",
21892
+ caller: "required"
21893
+ }), method(object({
21894
+ addonId: string().min(1),
21895
+ key: string().min(1)
21896
+ }), object({
21897
+ removed: boolean(),
21898
+ secretsCleared: number().int().nonnegative()
21899
+ }), {
21900
+ kind: "mutation",
21901
+ auth: "admin",
21902
+ caller: "required"
21903
+ });
21350
21904
  /**
21351
21905
  * record-entries - the ONE admin surface for every list of user-created entries
21352
21906
  * (D881): notification targets, brokers, LLM profiles, anything an addon
@@ -21485,6 +22039,12 @@ method(object({
21485
22039
  }), method(object({ deviceId: number().int() }), object({ refs: array(string()).readonly() }), {
21486
22040
  kind: "mutation",
21487
22041
  auth: "admin"
22042
+ }), method(object({
22043
+ refs: RefListSchema,
22044
+ onBehalfOf: string().min(1)
22045
+ }), object({ released: number().int() }), {
22046
+ kind: "mutation",
22047
+ auth: "admin"
21488
22048
  }), method(object({}), array(KeychainSecretMetaSchema).readonly(), { auth: "admin" }), method(object({}), KeychainStateSchema, { auth: "admin" });
21489
22049
  /**
21490
22050
  * device-history — the ONE mechanism for numeric history (D850).
@@ -22387,7 +22947,9 @@ var DiscoveredChildDeviceSchema = lazy(() => object({
22387
22947
  /** Upstream-system identity + rendering hints for the candidate. */
22388
22948
  sourceInfo: SourceInfoSchema.optional(),
22389
22949
  /** Nested entity-children for accordion preview — integrations populate, Hubs omit. */
22390
- children: array(DiscoveredChildDeviceSchema).readonly().optional()
22950
+ children: array(DiscoveredChildDeviceSchema).readonly().optional(),
22951
+ /** `childNativeId` of the gateway candidate in the same list this device sits behind. */
22952
+ parentStableId: string().optional()
22391
22953
  }));
22392
22954
  /**
22393
22955
  * Status block — populated by the integration after each
@@ -42770,6 +43332,79 @@ object({
42770
43332
  metadata: record(string(), unknown()).optional()
42771
43333
  });
42772
43334
  /**
43335
+ * HOW a provider can be found on the network. The provider only DECLARES the
43336
+ * mode; the `local-network` scan engine runs it once for every provider that
43337
+ * asked (one multicast browse, one host sweep) and hands the raw hits back to
43338
+ * `classifyDiscoveryHits`. A provider never opens a discovery socket itself,
43339
+ * except through a `custom` mode.
43340
+ */
43341
+ var DiscoveryModeSchema = discriminatedUnion("kind", [
43342
+ object({
43343
+ kind: literal("mdns"),
43344
+ /** DNS-SD service type, e.g. `_esphomelib._tcp`. */
43345
+ serviceType: string().min(1),
43346
+ /** Regular expression an instance name must match (case-insensitive). */
43347
+ instancePattern: string().optional()
43348
+ }),
43349
+ object({
43350
+ kind: literal("ws-discovery"),
43351
+ /** Probe types (e.g. `dn:NetworkVideoTransmitter`); omitted = any device. */
43352
+ types: array(string()).optional()
43353
+ }),
43354
+ object({
43355
+ kind: literal("ssdp"),
43356
+ searchTarget: string().min(1)
43357
+ }),
43358
+ object({
43359
+ kind: literal("udp-broadcast"),
43360
+ port: number().int().min(1).max(65535),
43361
+ payloadHex: string().regex(/^([0-9a-fA-F]{2})*$/),
43362
+ /** Port replies arrive on, when the device answers somewhere other than the source port. */
43363
+ listenPort: number().int().min(1).max(65535).optional()
43364
+ }),
43365
+ object({
43366
+ kind: literal("http-probe"),
43367
+ port: number().int().min(1).max(65535),
43368
+ path: string().startsWith("/"),
43369
+ method: _enum(["GET", "POST"]).default("GET"),
43370
+ bodyText: string().optional(),
43371
+ contentType: string().optional(),
43372
+ tls: boolean().default(false),
43373
+ /** Status codes that count as a hit. Omitted = any HTTP answer. */
43374
+ acceptStatus: array(number().int()).optional()
43375
+ }),
43376
+ object({
43377
+ kind: literal("custom"),
43378
+ /** Why the engine cannot do it (an allowlisted exception, e.g. a vendor UDP protocol). */
43379
+ reason: string().min(1)
43380
+ })
43381
+ ]);
43382
+ /** What the engine saw on the wire for ONE declared mode; the provider classifies it. */
43383
+ var DiscoveryHitSchema = object({
43384
+ /** Index into the provider's own `getDiscoveryModes()` array. */
43385
+ modeIndex: number().int().min(0),
43386
+ host: string(),
43387
+ port: number().int(),
43388
+ http: object({
43389
+ status: number().int(),
43390
+ headers: record(string(), string()),
43391
+ /** At most 16 KiB. */
43392
+ bodyText: string()
43393
+ }).optional(),
43394
+ mdns: object({
43395
+ instance: string(),
43396
+ host: string(),
43397
+ port: number().int(),
43398
+ txt: record(string(), string())
43399
+ }).optional(),
43400
+ udp: object({ payloadHex: string() }).optional(),
43401
+ wsd: object({
43402
+ xaddrs: array(string()),
43403
+ types: array(string()),
43404
+ scopes: array(string())
43405
+ }).optional()
43406
+ });
43407
+ /**
42773
43408
  * Candidate handed back from discovery and accepted by
42774
43409
  * `adoptDiscoveredDevice`. Shape mirrors the in-process
42775
43410
  * `DiscoveredDevice` interface used by `DeviceDiscovery`.
@@ -42804,7 +43439,17 @@ var DiscoveryCandidateSchema = object({
42804
43439
  /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
42805
43440
  onboardedDeviceId: number().optional(),
42806
43441
  /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
42807
- onboardedName: string().optional()
43442
+ onboardedName: string().optional(),
43443
+ /**
43444
+ * This candidate is an INTEGRATION to set up (a gateway, a hub, a controller),
43445
+ * not a device to adopt. Replaces the `prefilledConfig.setupIntegration` marker,
43446
+ * which is still read for one release.
43447
+ */
43448
+ setupIntegration: boolean().optional(),
43449
+ /** `stableId` of the gateway candidate this device was found BEHIND. */
43450
+ parentStableId: string().optional(),
43451
+ /** `false` = shown for context but not pickable. Default true. */
43452
+ selectable: boolean().optional()
42808
43453
  });
42809
43454
  /**
42810
43455
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -42901,6 +43546,24 @@ var deviceProviderCapability = {
42901
43546
  auth: "admin"
42902
43547
  }),
42903
43548
  /**
43549
+ * How this provider is found on the network. `[]` = the provider has no
43550
+ * declarative mode and keeps answering `discoverDevices` itself.
43551
+ */
43552
+ getDiscoveryModes: method(object({}), array(DiscoveryModeSchema)),
43553
+ /**
43554
+ * Turn the engine's raw hits into candidates. The provider may make
43555
+ * follow-up UNICAST requests to a hit host (an authenticated call, an
43556
+ * identity check) and never sweeps a subnet. `params` is the per-scan form
43557
+ * from `getDiscoveryParamsSchema`.
43558
+ */
43559
+ classifyDiscoveryHits: method(object({
43560
+ hits: array(DiscoveryHitSchema),
43561
+ params: record(string(), unknown()).optional()
43562
+ }), array(DiscoveryCandidateSchema), {
43563
+ kind: "mutation",
43564
+ auth: "admin"
43565
+ }),
43566
+ /**
42904
43567
  * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
42905
43568
  * provider accepts (e.g. Gree's broadcast address for a different subnet).
42906
43569
  * `null` when the provider takes no extra scan params — the generic
@@ -43665,17 +44328,23 @@ method(object({
43665
44328
  }), method(object({}), object({ providers: array(object({
43666
44329
  addonId: string(),
43667
44330
  label: string()
43668
- })).readonly() }), { auth: "admin" }), method(object({}), object({ groups: array(object({
43669
- addonId: string(),
43670
- label: string(),
43671
- candidates: array(DiscoveryCandidateSchema).readonly(),
43672
- error: string().nullable()
43673
- })).readonly() }), {
43674
- kind: "mutation",
43675
- auth: "admin"
43676
- }), method(object({
44331
+ })).readonly() }), { auth: "admin" }), method(
44332
+ /** `subnets`: extra CIDRs for THIS scan, on top of the hub's own and the saved list. */
44333
+ object({ subnets: array(string()).optional() }),
44334
+ object({ groups: array(object({
44335
+ addonId: string(),
44336
+ label: string(),
44337
+ candidates: array(DiscoveryCandidateSchema).readonly(),
44338
+ error: string().nullable()
44339
+ })).readonly() }),
44340
+ {
44341
+ kind: "mutation",
44342
+ auth: "admin"
44343
+ }
44344
+ ), method(object({
43677
44345
  addonId: string(),
43678
- params: record(string(), unknown()).optional()
44346
+ params: record(string(), unknown()).optional(),
44347
+ subnets: array(string()).optional()
43679
44348
  }), object({ candidates: array(DiscoveryCandidateSchema).readonly() }), {
43680
44349
  kind: "mutation",
43681
44350
  auth: "admin"
@@ -56342,6 +57011,12 @@ var TerminalInstanceInfoSchema = object({
56342
57011
  instanceId: string(),
56343
57012
  cameraStableId: string(),
56344
57013
  nodeId: string(),
57014
+ /**
57015
+ * False while the node the instance is bound to is not in the cluster
57016
+ * topology (forgotten, renamed, re-paired). The instance is kept; the
57017
+ * operator re-points it with `updateInstance({ targetNodeId })`.
57018
+ */
57019
+ nodeInCluster: boolean(),
56345
57020
  profileId: string(),
56346
57021
  profileLabel: string(),
56347
57022
  name: string(),
@@ -56391,6 +57066,8 @@ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
56391
57066
  auth: "admin"
56392
57067
  }), method(object({
56393
57068
  instanceId: string().min(1),
57069
+ /** Re-point the instance to another node; the profile must exist there. */
57070
+ targetNodeId: string().min(1).max(256).optional(),
56394
57071
  name: string().trim().min(1).max(160).optional(),
56395
57072
  executable: string().max(1024).optional(),
56396
57073
  args: array(string().max(2048)).max(64).optional(),
@@ -63017,7 +63694,9 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
63017
63694
  id: string(),
63018
63695
  settings: record(string(), unknown()),
63019
63696
  /** Keys the form's schema declares secret (by type) — keychain-stored (D787). */
63020
- secretKeys: array(string()).optional()
63697
+ secretKeys: array(string()).optional(),
63698
+ /** The integration's own addon applies the change in-process and asks the hub not to respawn it. */
63699
+ skipRestart: boolean().optional()
63021
63700
  }), object({ success: boolean() }), {
63022
63701
  kind: "mutation",
63023
63702
  auth: "admin"
@@ -63645,6 +64324,39 @@ var TlsStatusSchema = object({
63645
64324
  hasCa: boolean()
63646
64325
  }).nullable()
63647
64326
  });
64327
+ /** One provider's ask of the scan engine: the modes it declared. */
64328
+ var ScanRequestSchema = object({
64329
+ providerId: string().min(1),
64330
+ modes: array(DiscoveryModeSchema)
64331
+ });
64332
+ var ScanErrorSchema = object({
64333
+ /** Absent for a failure that is the scan's own (a bad subnet, a socket that would not open). */
64334
+ providerId: string().optional(),
64335
+ /** The mode family that failed (`mdns`, `http-probe`, …). */
64336
+ mode: string().optional(),
64337
+ message: string()
64338
+ });
64339
+ /** What one scan did, per mode family; the engine logs the same numbers once. */
64340
+ var ScanStatsSchema = object({
64341
+ hostsProbed: number().int(),
64342
+ hits: number().int(),
64343
+ timeouts: number().int(),
64344
+ byMode: record(string(), object({
64345
+ probed: number().int(),
64346
+ hits: number().int(),
64347
+ timeouts: number().int()
64348
+ }))
64349
+ });
64350
+ var ScanNetworkResultSchema = object({
64351
+ hits: array(object({
64352
+ providerId: string(),
64353
+ hit: DiscoveryHitSchema
64354
+ })),
64355
+ /** CIDRs actually covered: the hub's own /24s, the saved list and the per-call list. */
64356
+ subnetsScanned: array(string()),
64357
+ errors: array(ScanErrorSchema),
64358
+ stats: ScanStatsSchema
64359
+ });
63648
64360
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
63649
64361
  /**
63650
64362
  * LEGACY HINT — do not send from new code. Kept optional so clients
@@ -63699,6 +64411,16 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
63699
64411
  }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
63700
64412
  kind: "mutation",
63701
64413
  auth: "admin"
64414
+ }), method(object({
64415
+ requests: array(ScanRequestSchema),
64416
+ subnets: array(string()).optional()
64417
+ }), ScanNetworkResultSchema, {
64418
+ kind: "mutation",
64419
+ auth: "admin",
64420
+ timeoutMs: 9e4
64421
+ }), method(_void(), object({ subnets: array(string()) }), { auth: "admin" }), method(object({ subnets: array(string()) }), object({ subnets: array(string()) }), {
64422
+ kind: "mutation",
64423
+ auth: "admin"
63702
64424
  }), method(_void(), TlsStatusSchema, {
63703
64425
  kind: "mutation",
63704
64426
  auth: "admin"
@@ -73281,6 +74003,17 @@ var BaseDeviceProvider = class extends BaseAddon {
73281
74003
  async discoverDevices(_input) {
73282
74004
  return [];
73283
74005
  }
74006
+ /**
74007
+ * How the scan engine finds this provider. Default `[]`: the provider is
74008
+ * not migrated and keeps answering `discoverDevices` itself.
74009
+ */
74010
+ async getDiscoveryModes() {
74011
+ return [];
74012
+ }
74013
+ /** Turn the scan engine's raw hits into candidates. Default: none. */
74014
+ async classifyDiscoveryHits(_input) {
74015
+ return [];
74016
+ }
73284
74017
  /** Extra per-scan input form (e.g. a broadcast address for another subnet).
73285
74018
  * Null = no extra params. Override in providers that support scoped scans. */
73286
74019
  async getDiscoveryParamsSchema() {
@@ -74149,6 +74882,13 @@ Object.freeze({
74149
74882
  access: "create",
74150
74883
  role: "admin"
74151
74884
  },
74885
+ "addonSettings.getDeclaredSettingsKeys": {
74886
+ capName: "addon-settings",
74887
+ capScope: "system",
74888
+ addonId: null,
74889
+ access: "view",
74890
+ role: "admin"
74891
+ },
74152
74892
  "addonSettings.getDeviceSettings": {
74153
74893
  capName: "addon-settings",
74154
74894
  capScope: "system",
@@ -74170,6 +74910,13 @@ Object.freeze({
74170
74910
  access: "view",
74171
74911
  role: "admin"
74172
74912
  },
74913
+ "addonSettings.unsetStoredSettingsKeys": {
74914
+ capName: "addon-settings",
74915
+ capScope: "system",
74916
+ addonId: null,
74917
+ access: "create",
74918
+ role: "admin"
74919
+ },
74173
74920
  "addonSettings.updateDeviceSettings": {
74174
74921
  capName: "addon-settings",
74175
74922
  capScope: "system",
@@ -76585,6 +77332,13 @@ Object.freeze({
76585
77332
  access: "create",
76586
77333
  role: "admin"
76587
77334
  },
77335
+ "deviceProvider.classifyDiscoveryHits": {
77336
+ capName: "device-provider",
77337
+ capScope: "system",
77338
+ addonId: null,
77339
+ access: "create",
77340
+ role: "admin"
77341
+ },
76588
77342
  "deviceProvider.createDevice": {
76589
77343
  capName: "device-provider",
76590
77344
  capScope: "system",
@@ -76613,6 +77367,13 @@ Object.freeze({
76613
77367
  access: "view",
76614
77368
  role: "admin"
76615
77369
  },
77370
+ "deviceProvider.getDiscoveryModes": {
77371
+ capName: "device-provider",
77372
+ capScope: "system",
77373
+ addonId: null,
77374
+ access: "view",
77375
+ role: "admin"
77376
+ },
76616
77377
  "deviceProvider.getDiscoveryParamsSchema": {
76617
77378
  capName: "device-provider",
76618
77379
  capScope: "system",
@@ -77390,6 +78151,13 @@ Object.freeze({
77390
78151
  access: "create",
77391
78152
  role: "admin"
77392
78153
  },
78154
+ "keychain.releaseRecordRefs": {
78155
+ capName: "keychain",
78156
+ capScope: "system",
78157
+ addonId: null,
78158
+ access: "create",
78159
+ role: "admin"
78160
+ },
77393
78161
  "keychain.resolve": {
77394
78162
  capName: "keychain",
77395
78163
  capScope: "system",
@@ -77810,6 +78578,13 @@ Object.freeze({
77810
78578
  access: "view",
77811
78579
  role: "admin"
77812
78580
  },
78581
+ "localNetwork.getScanSubnets": {
78582
+ capName: "local-network",
78583
+ capScope: "system",
78584
+ addonId: null,
78585
+ access: "view",
78586
+ role: "admin"
78587
+ },
77813
78588
  "localNetwork.getTlsStatus": {
77814
78589
  capName: "local-network",
77815
78590
  capScope: "system",
@@ -77859,6 +78634,13 @@ Object.freeze({
77859
78634
  access: "create",
77860
78635
  role: "admin"
77861
78636
  },
78637
+ "localNetwork.scanNetwork": {
78638
+ capName: "local-network",
78639
+ capScope: "system",
78640
+ addonId: null,
78641
+ access: "create",
78642
+ role: "admin"
78643
+ },
77862
78644
  "localNetwork.setAddressRole": {
77863
78645
  capName: "local-network",
77864
78646
  capScope: "system",
@@ -77880,6 +78662,13 @@ Object.freeze({
77880
78662
  access: "create",
77881
78663
  role: "admin"
77882
78664
  },
78665
+ "localNetwork.setScanSubnets": {
78666
+ capName: "local-network",
78667
+ capScope: "system",
78668
+ addonId: null,
78669
+ access: "create",
78670
+ role: "admin"
78671
+ },
77883
78672
  "localNetwork.setViewerEndpoints": {
77884
78673
  capName: "local-network",
77885
78674
  capScope: "system",
@@ -82101,6 +82890,13 @@ Object.freeze({
82101
82890
  access: "create",
82102
82891
  role: "admin"
82103
82892
  },
82893
+ "storageAdmin.dropOrphanedKind": {
82894
+ capName: "storage-admin",
82895
+ capScope: "system",
82896
+ addonId: null,
82897
+ access: "delete",
82898
+ role: "admin"
82899
+ },
82104
82900
  "storageAdmin.listKinds": {
82105
82901
  capName: "storage-admin",
82106
82902
  capScope: "system",
@@ -82122,6 +82918,27 @@ Object.freeze({
82122
82918
  access: "view",
82123
82919
  role: "admin"
82124
82920
  },
82921
+ "storageAdmin.scanStaleData": {
82922
+ capName: "storage-admin",
82923
+ capScope: "system",
82924
+ addonId: null,
82925
+ access: "create",
82926
+ role: "admin"
82927
+ },
82928
+ "storageAdmin.staleDataReport": {
82929
+ capName: "storage-admin",
82930
+ capScope: "system",
82931
+ addonId: null,
82932
+ access: "view",
82933
+ role: "admin"
82934
+ },
82935
+ "storageAdmin.unsetStaleSettingsKey": {
82936
+ capName: "storage-admin",
82937
+ capScope: "system",
82938
+ addonId: null,
82939
+ access: "create",
82940
+ role: "admin"
82941
+ },
82125
82942
  "storageEvictable.evict": {
82126
82943
  capName: "storage-evictable",
82127
82944
  capScope: "system",
@@ -95073,36 +95890,22 @@ function buildHmAdoptionProvider(deps) {
95073
95890
  }
95074
95891
  };
95075
95892
  }
95076
- //#endregion
95077
- //#region src/homematic-ccu-discovery.ts
95078
- /** `prefilledConfig` flag: the scan opens the integration form instead of adopting a device. */
95079
- var HOMEMATIC_SETUP_INTEGRATION_KEY = "setupIntegration";
95080
- var JSON_RPC_PATH$2 = "/api/homematic.cgi";
95081
- /** How long one host gets to answer before the sweep moves on. */
95082
- var HOMEMATIC_DISCOVERY_TIMEOUT_MS = 1200;
95083
- /** Upper bound on the hosts one scan probes, so a wide interface list cannot flood the LAN. */
95084
- var MAX_HOSTS = 1024;
95085
- var fetchPost = async (url, body, timeoutMs) => {
95086
- const response = await fetch(url, {
95087
- method: "POST",
95088
- headers: { "Content-Type": "application/json" },
95089
- body,
95090
- signal: AbortSignal.timeout(timeoutMs)
95091
- });
95092
- return {
95093
- status: response.status,
95094
- text: await response.text()
95095
- };
95096
- };
95097
- function osAddresses() {
95098
- const out = [];
95099
- for (const list of Object.values(networkInterfaces())) for (const a of list ?? []) out.push({
95100
- family: String(a.family),
95101
- address: a.address,
95102
- internal: a.internal
95103
- });
95104
- return out;
95105
- }
95893
+ /** The question the WebUI asks before it shows a login; the engine sends it to every host once. */
95894
+ var HOMEMATIC_DISCOVERY_MODES = [{
95895
+ kind: "http-probe",
95896
+ port: 80,
95897
+ path: "/api/homematic.cgi",
95898
+ method: "POST",
95899
+ bodyText: JSON.stringify({
95900
+ method: "CCU.getAuthEnabled",
95901
+ params: {},
95902
+ jsonrpc: "1.1",
95903
+ id: 0
95904
+ }),
95905
+ contentType: "application/json",
95906
+ tls: false,
95907
+ acceptStatus: [200]
95908
+ }];
95106
95909
  /**
95107
95910
  * Read the answer of `CCU.getAuthEnabled`, the question the WebUI asks before
95108
95911
  * it shows a login. A CCU answers a boolean; one that already requires a login
@@ -95129,63 +95932,26 @@ function parseAuthEnabledAnswer(status, text) {
95129
95932
  if (result === false || result === "false") return { authEnabled: false };
95130
95933
  return null;
95131
95934
  }
95132
- /**
95133
- * The hosts to ask: every address of the /24 of each external IPv4 address of
95134
- * this machine, minus the network and broadcast addresses and this machine itself.
95135
- */
95136
- function lanHostsToProbe(addresses) {
95137
- const own = /* @__PURE__ */ new Set();
95138
- const subnets = [];
95139
- for (const a of addresses) {
95140
- if (a.internal || a.family !== "IPv4" && a.family !== "4") continue;
95141
- own.add(a.address);
95142
- const prefix = a.address.split(".").slice(0, 3).join(".");
95143
- if (prefix.split(".").length === 3 && !subnets.includes(prefix)) subnets.push(prefix);
95144
- }
95145
- const hosts = [];
95146
- for (const prefix of subnets) for (let last = 1; last <= 254; last++) {
95147
- const host = `${prefix}.${last}`;
95148
- if (!own.has(host)) hosts.push(host);
95149
- }
95150
- return hosts.slice(0, MAX_HOSTS);
95151
- }
95152
95935
  /** Slugify a host into a readable, stable id fragment. */
95153
95936
  function slugifyHost(value) {
95154
95937
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
95155
95938
  }
95156
- /**
95157
- * Sweep the local /24 networks for CCU3 / RaspberryMatic WebUIs: each host is
95158
- * asked `CCU.getAuthEnabled` on the JSON-RPC endpoint, with a short timeout and
95159
- * a bounded number of probes in flight.
95160
- */
95161
- async function browseHomematicCcus(options = {}) {
95162
- const { addresses = osAddresses, post = fetchPost, concurrency = 64, timeoutMs = HOMEMATIC_DISCOVERY_TIMEOUT_MS } = options;
95163
- const hosts = lanHostsToProbe(addresses());
95164
- const body = JSON.stringify({
95165
- method: "CCU.getAuthEnabled",
95166
- params: {},
95167
- jsonrpc: "1.1",
95168
- id: 0
95169
- });
95939
+ /** CCUs among the engine's http-probe hits: the hosts whose answer is the JSON-RPC one. */
95940
+ function ccusFromHits(hits) {
95170
95941
  const found = [];
95171
- let next = 0;
95172
- const worker = async () => {
95173
- for (;;) {
95174
- const host = hosts[next++];
95175
- if (host === void 0) return;
95176
- try {
95177
- const reply = await post(`http://${host}${JSON_RPC_PATH$2}`, body, timeoutMs);
95178
- const answer = parseAuthEnabledAnswer(reply.status, reply.text);
95179
- if (answer !== null) found.push({
95180
- host,
95181
- name: `Homematic CCU (${host})`,
95182
- id: slugifyHost(host),
95183
- authEnabled: answer.authEnabled
95184
- });
95185
- } catch {}
95186
- }
95187
- };
95188
- await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
95942
+ const seen = /* @__PURE__ */ new Set();
95943
+ for (const hit of hits) {
95944
+ if (hit.http === void 0 || seen.has(hit.host)) continue;
95945
+ const answer = parseAuthEnabledAnswer(hit.http.status, hit.http.bodyText);
95946
+ if (answer === null) continue;
95947
+ seen.add(hit.host);
95948
+ found.push({
95949
+ host: hit.host,
95950
+ name: `Homematic CCU (${hit.host})`,
95951
+ id: slugifyHost(hit.host),
95952
+ authEnabled: answer.authEnabled
95953
+ });
95954
+ }
95189
95955
  return found.sort((a, b) => a.host.localeCompare(b.host, void 0, { numeric: true }));
95190
95956
  }
95191
95957
  /** CCUs whose host is already stored on an integration are not proposed again. */
@@ -95196,11 +95962,10 @@ function proposeHomematicCcus(found, existingHosts) {
95196
95962
  function homematicDiscoveryCandidates(proposals) {
95197
95963
  return proposals.map((found) => ({
95198
95964
  stableId: `homematic:${found.id}`,
95965
+ type: DeviceType.Gateway,
95199
95966
  suggestedName: found.name,
95200
- prefilledConfig: {
95201
- host: found.host,
95202
- [HOMEMATIC_SETUP_INTEGRATION_KEY]: true
95203
- }
95967
+ prefilledConfig: { host: found.host },
95968
+ setupIntegration: true
95204
95969
  }));
95205
95970
  }
95206
95971
  function homematicDiscoverySummary(foundCount, proposedCount) {
@@ -112472,6 +113237,19 @@ var CircuitBreakerOpenError = class extends BaseHomematicError {};
112472
113237
  var AuthFailureError = class extends BaseHomematicError {};
112473
113238
  var InternalBackendError = class extends BaseHomematicError {};
112474
113239
  var CommandSupersededError = class extends BaseHomematicError {};
113240
+ var CcuManagementError = class extends BaseHomematicError {
113241
+ /** The XML-RPC / JSON-RPC method that failed. */
113242
+ method;
113243
+ /** The underlying typed error (or whatever was thrown). */
113244
+ original;
113245
+ constructor(method, original) {
113246
+ const detail = original instanceof Error ? original.message : String(original);
113247
+ super(`${method} failed: ${detail}`);
113248
+ this.method = method;
113249
+ this.original = original;
113250
+ }
113251
+ };
113252
+ var GroupWriteVerificationError = class extends BaseHomematicError {};
112475
113253
  var DescriptionNotFoundError = class extends BaseHomematicError {};
112476
113254
  var InvalidStateTransitionError = class extends BaseHomematicError {};
112477
113255
  var TimeoutError = class extends BaseHomematicError {};
@@ -113225,6 +114003,54 @@ var InterfaceClient = class {
113225
114003
  const result = await this.callGuarded("getInstallMode", []);
113226
114004
  return typeof result === "number" ? result : 0;
113227
114005
  }
114006
+ /**
114007
+ * `listBidcosInterfaces()` → the radio gateways of a BidCos interface.
114008
+ *
114009
+ * Source: HomeMatic XML-RPC interface spec (rfd), as consumed by the WebUI in
114010
+ * eq-3/occu `WebUI/www/api/methods/interface/listbidcosinterfaces.tcl`
114011
+ * (fields ADDRESS, DESCRIPTION, DUTY_CYCLE, CONNECTED, DEFAULT,
114012
+ * FIRMWARE_VERSION, TYPE). A field the CCU did not send is `null`, never 0.
114013
+ */
114014
+ async listBidcosInterfaces() {
114015
+ const result = await this.managementCall("listBidcosInterfaces", []);
114016
+ if (!Array.isArray(result)) return [];
114017
+ const gateways = [];
114018
+ for (const entry of result) if (isStruct(entry)) gateways.push(toRadioGateway(entry));
114019
+ return gateways;
114020
+ }
114021
+ /**
114022
+ * `setInstallMode(on[, seconds[, mode]])` — open or close the pairing window.
114023
+ *
114024
+ * Source: HomeMatic XML-RPC interface spec; the WebUI forwards the same call
114025
+ * in eq-3/occu `WebUI/www/api/methods/interface/setinstallmode.tcl` and
114026
+ * `setinstallmodehmip.tcl` (`setInstallMode(bool on, int time)`). `mode`
114027
+ * (1 normal, 2 with the HomeMatic Secure key) is BidCos-only and is sent only
114028
+ * when given.
114029
+ */
114030
+ async setInstallMode(request) {
114031
+ const params = [request.on];
114032
+ if (request.seconds !== void 0) params.push(request.seconds);
114033
+ if (request.mode !== void 0) params.push(request.mode);
114034
+ await this.managementCall("setInstallMode", params);
114035
+ }
114036
+ /**
114037
+ * `getInstallMode()` → seconds left of the pairing window, or `null` when the
114038
+ * CCU answered something that is not an integer (never a fake 0).
114039
+ */
114040
+ async getInstallModeRemaining() {
114041
+ const result = await this.managementCall("getInstallMode", []);
114042
+ return typeof result === "number" && Number.isInteger(result) ? result : null;
114043
+ }
114044
+ /**
114045
+ * `deleteDevice(address, flags)` — remove a device from the interface.
114046
+ *
114047
+ * Source: eq-3/occu `WebUI/www/api/methods/interface/deletedevice.tcl`:
114048
+ * flags 0x01 reset to factory, 0x02 force (also when unreachable), 0x04 defer
114049
+ * until the device is next reachable.
114050
+ */
114051
+ async deleteDevice(address, flags) {
114052
+ await this.managementCall("deleteDevice", [address, flags]);
114053
+ }
113228
114054
  /** `getVersion()` (bypass) → backend version string. */
113229
114055
  async getVersion() {
113230
114056
  return scalarToString(await this.callGuarded("getVersion", [], { bypassBreaker: true }));
@@ -113235,6 +114061,18 @@ var InterfaceClient = class {
113235
114061
  return Array.isArray(result) ? result.map(scalarToString) : [];
113236
114062
  }
113237
114063
  /**
114064
+ * A management call: guarded like any other, but a failure is rethrown as a
114065
+ * {@link CcuManagementError} naming the method and keeping the CCU fault text.
114066
+ */
114067
+ async managementCall(method, params) {
114068
+ try {
114069
+ return await this.callGuarded(method, params);
114070
+ } catch (err) {
114071
+ if (err instanceof BaseHomematicError) throw new CcuManagementError(method, err);
114072
+ throw err;
114073
+ }
114074
+ }
114075
+ /**
113238
114076
  * Invoke `method`, gating non-bypass calls behind the circuit breaker.
113239
114077
  * Bypass methods skip the breaker entirely (no success/failure recorded).
113240
114078
  */
@@ -113289,17 +114127,48 @@ function asDeviceDescriptionArray(value) {
113289
114127
  function asParamsetDescription(value) {
113290
114128
  return asStruct(value);
113291
114129
  }
114130
+ function stringOrNull(value) {
114131
+ return typeof value === "string" && value !== "" ? value : null;
114132
+ }
114133
+ function numberOrNull(value) {
114134
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
114135
+ }
114136
+ function flagOrNull(value) {
114137
+ if (typeof value === "boolean") return value;
114138
+ if (value === 1) return true;
114139
+ if (value === 0) return false;
114140
+ return null;
114141
+ }
114142
+ function toRadioGateway(entry) {
114143
+ const connected = flagOrNull(entry["CONNECTED"]);
114144
+ return {
114145
+ address: stringOrNull(entry["ADDRESS"]) ?? "",
114146
+ description: stringOrNull(entry["DESCRIPTION"]),
114147
+ dutyCycle: numberOrNull(entry["DUTY_CYCLE"]),
114148
+ connected,
114149
+ isDefault: flagOrNull(entry["DEFAULT"]) === true,
114150
+ firmware: stringOrNull(entry["FIRMWARE_VERSION"]),
114151
+ type: stringOrNull(entry["TYPE"])
114152
+ };
114153
+ }
113292
114154
  var SESSION_ID_PARAM = "_session_id_";
113293
114155
  var JsonRpcMethod = {
113294
114156
  CCU_GET_AUTH_ENABLED: "CCU.getAuthEnabled",
113295
114157
  CCU_GET_HTTPS_REDIRECT_ENABLED: "CCU.getHttpsRedirectEnabled",
114158
+ CCU_GET_VERSION: "CCU.getVersion",
114159
+ CCU_GET_SERIAL: "CCU.getSerial",
114160
+ CCU_GET_HEATING_GROUP_LIST: "CCU.getHeatingGroupList",
113296
114161
  DEVICE_LIST_ALL_DETAIL: "Device.listAllDetail",
114162
+ DEVICE_GET_REGA_ID_BY_ADDRESS: "Device.getReGaIDByAddress",
113297
114163
  DEVICE_SET_NAME: "Device.setName",
113298
114164
  CHANNEL_SET_NAME: "Channel.setName",
113299
114165
  INTERFACE_LIST_INTERFACES: "Interface.listInterfaces",
113300
114166
  INTERFACE_LIST_DEVICES: "Interface.listDevices",
114167
+ INTERFACE_IS_PRESENT: "Interface.isPresent",
114168
+ INTERFACE_GET_DUTY_CYCLE: "Interface.getDutyCycle",
113301
114169
  INTERFACE_GET_INSTALL_MODE: "Interface.getInstallMode",
113302
114170
  INTERFACE_SET_INSTALL_MODE_HMIP: "Interface.setInstallModeHMIP",
114171
+ INTERFACE_SET_METADATA: "Interface.setMetadata",
113303
114172
  PROGRAM_GET_ALL: "Program.getAll",
113304
114173
  PROGRAM_EXECUTE: "Program.execute",
113305
114174
  SYSVAR_GET_ALL: "SysVar.getAll",
@@ -113508,6 +114377,691 @@ var SessionManager = class {
113508
114377
  return Math.min(exp, MAX_BACKOFF_MS);
113509
114378
  }
113510
114379
  };
114380
+ var FORM_CONTENT_TYPE = "application/x-www-form-urlencoded; charset=UTF-8";
114381
+ var ESCAPE_SAFE = /[A-Za-z0-9@*_+\-./]/;
114382
+ function jsEscape(text) {
114383
+ let out = "";
114384
+ for (const ch of text) {
114385
+ const unit = ch.charCodeAt(0);
114386
+ if (ESCAPE_SAFE.test(ch)) out += ch;
114387
+ else if (ch.length === 1 && unit < 256) out += `%${unit.toString(16).toUpperCase().padStart(2, "0")}`;
114388
+ else for (let i = 0; i < ch.length; i += 1) out += `%u${ch.charCodeAt(i).toString(16).toUpperCase().padStart(4, "0")}`;
114389
+ }
114390
+ return out;
114391
+ }
114392
+ var WebUiHttpClient = class {
114393
+ base;
114394
+ timeoutMs;
114395
+ fetchFn;
114396
+ dispatcher;
114397
+ constructor(options) {
114398
+ this.base = options.url.replace(/\/+$/, "");
114399
+ this.timeoutMs = options.timeoutMs ?? TIMEOUTS.rpc;
114400
+ this.dispatcher = options.rejectUnauthorized === false ? new import_undici.Agent({ connect: { rejectUnauthorized: false } }) : void 0;
114401
+ this.fetchFn = options.fetch ?? this.defaultFetch.bind(this);
114402
+ }
114403
+ get(path, sessionId) {
114404
+ return this.request("GET", path, sessionId, void 0);
114405
+ }
114406
+ post(path, sessionId, body) {
114407
+ return this.request("POST", path, sessionId, body);
114408
+ }
114409
+ async close() {
114410
+ await this.dispatcher?.close();
114411
+ }
114412
+ async request(method, path, sessionId, body) {
114413
+ const url = `${this.base}${path}?sid=${encodeURIComponent(sessionId)}`;
114414
+ const controller = new AbortController();
114415
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
114416
+ try {
114417
+ const response = await this.fetchFn(url, {
114418
+ method,
114419
+ headers: body === void 0 ? {} : { "Content-Type": FORM_CONTENT_TYPE },
114420
+ body,
114421
+ signal: controller.signal
114422
+ });
114423
+ const text = await response.text();
114424
+ if (response.status < 200 || response.status >= 300) throw new ClientError(`HTTP ${response.status}`);
114425
+ return text;
114426
+ } catch (err) {
114427
+ const mapped = err instanceof ClientError ? err : mapTransportError(err);
114428
+ throw new CcuManagementError(`${method} ${path}`, /* @__PURE__ */ new Error(`${mapped.name}: ${mapped.message.split(sessionId).join("<sid>")}`));
114429
+ } finally {
114430
+ clearTimeout(timer);
114431
+ }
114432
+ }
114433
+ async defaultFetch(url, init) {
114434
+ const req = {
114435
+ method: init.method,
114436
+ headers: init.headers,
114437
+ signal: init.signal
114438
+ };
114439
+ if (init.body !== void 0) req.body = init.body;
114440
+ if (this.dispatcher) req.dispatcher = this.dispatcher;
114441
+ return (0, import_undici.fetch)(url, req);
114442
+ }
114443
+ };
114444
+ function escapeRegaString(s) {
114445
+ return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
114446
+ }
114447
+ var CONTROL_CHARS = /[\u0000-\u001F]/g;
114448
+ function sanitizeJsonControlChars(s) {
114449
+ return s.replace(CONTROL_CHARS, "");
114450
+ }
114451
+ function decodeRegaName(encoded) {
114452
+ const bytes = [];
114453
+ for (let i = 0; i < encoded.length; i += 1) {
114454
+ const ch = encoded[i];
114455
+ if (ch === "%" && i + 2 < encoded.length) {
114456
+ const hex = encoded.slice(i + 1, i + 3);
114457
+ const byte = Number.parseInt(hex, 16);
114458
+ if (!Number.isNaN(byte) && /^[0-9a-fA-F]{2}$/.test(hex)) {
114459
+ bytes.push(byte);
114460
+ i += 2;
114461
+ continue;
114462
+ }
114463
+ }
114464
+ if (ch === "+") {
114465
+ bytes.push(32);
114466
+ continue;
114467
+ }
114468
+ bytes.push(ch.charCodeAt(0) & 255);
114469
+ }
114470
+ return Buffer.from(bytes).toString("latin1");
114471
+ }
114472
+ function substitute(body, params) {
114473
+ let out = body;
114474
+ for (const [key, value] of Object.entries(params)) {
114475
+ const escaped = escapeRegaString(String(value));
114476
+ out = out.split(`##${key}##`).join(escaped);
114477
+ }
114478
+ return out;
114479
+ }
114480
+ async function runScript(client, sessionId, body, params) {
114481
+ const script = params ? substitute(body, params) : body;
114482
+ const result = await client.post(JsonRpcMethod.REGA_RUN_SCRIPT, { script }, sessionId !== void 0 ? { sessionId } : void 0);
114483
+ if (result === void 0 || result === null) throw new ClientError("ReGa.runScript returned no result");
114484
+ if (typeof result === "string") return JSON.parse(sanitizeJsonControlChars(result));
114485
+ return result;
114486
+ }
114487
+ var SET_SYSTEM_VARIABLE = `object sv = dom.GetObject("##name##");
114488
+ if (sv) {
114489
+ sv.State("##value##");
114490
+ }`;
114491
+ var SET_PROGRAM_STATE = `object prog = dom.GetObject(##id##);
114492
+ if (prog) {
114493
+ prog.Active(##state##);
114494
+ }`;
114495
+ var SYSVAR_DESCRIPTIONS = `string sSysVarId;
114496
+ string sSep = "";
114497
+ WriteLine("[");
114498
+ foreach(sSysVarId, dom.GetObject(ID_SYSTEM_VARIABLES).EnumUsedIDs()) {
114499
+ object oSysVar = dom.GetObject(sSysVarId);
114500
+ Write(sSep);
114501
+ Write("{");
114502
+ Write("\\"id\\":");
114503
+ Write(oSysVar.ID());
114504
+ Write(",\\"description\\":\\"");
114505
+ Write(oSysVar.DPInfo().UriEncode());
114506
+ Write("\\"}");
114507
+ sSep = ",";
114508
+ }
114509
+ WriteLine("]");`;
114510
+ var GET_ROOMS_FUNCTIONS = `string sChnId;
114511
+ string sRoomId;
114512
+ string sFuncId;
114513
+ string sChnSepR = "";
114514
+ string sChnSepF = "";
114515
+ Write("{\\"rooms\\":{");
114516
+ foreach(sChnId, dom.GetObject(ID_CHANNELS).EnumUsedIDs()) {
114517
+ object oChn = dom.GetObject(sChnId);
114518
+ if (oChn) {
114519
+ string sAddr = oChn.Address();
114520
+ object oRooms = oChn.ChnRoom();
114521
+ if (oRooms && oRooms.Count() > 0) {
114522
+ Write(sChnSepR);
114523
+ Write("\\"");
114524
+ Write(sAddr);
114525
+ Write("\\":[");
114526
+ string sRoomSep = "";
114527
+ foreach(sRoomId, oRooms) {
114528
+ object oRoom = dom.GetObject(sRoomId);
114529
+ Write(sRoomSep);
114530
+ Write("\\"");
114531
+ Write(oRoom.Name().UriEncode());
114532
+ Write("\\"");
114533
+ sRoomSep = ",";
114534
+ }
114535
+ Write("]");
114536
+ sChnSepR = ",";
114537
+ }
114538
+ }
114539
+ }
114540
+ Write("},\\"functions\\":{");
114541
+ foreach(sChnId, dom.GetObject(ID_CHANNELS).EnumUsedIDs()) {
114542
+ object oChn = dom.GetObject(sChnId);
114543
+ if (oChn) {
114544
+ string sAddr = oChn.Address();
114545
+ object oFuncs = oChn.ChnFunction();
114546
+ if (oFuncs && oFuncs.Count() > 0) {
114547
+ Write(sChnSepF);
114548
+ Write("\\"");
114549
+ Write(sAddr);
114550
+ Write("\\":[");
114551
+ string sFuncSep = "";
114552
+ foreach(sFuncId, oFuncs) {
114553
+ object oFunc = dom.GetObject(sFuncId);
114554
+ Write(sFuncSep);
114555
+ Write("\\"");
114556
+ Write(oFunc.Name().UriEncode());
114557
+ Write("\\"");
114558
+ sFuncSep = ",";
114559
+ }
114560
+ Write("]");
114561
+ sChnSepF = ",";
114562
+ }
114563
+ }
114564
+ }
114565
+ Write("}}");`;
114566
+ var GET_SERVICE_MESSAGES = `string sAID;
114567
+ string sSep = "";
114568
+ Write("[");
114569
+ foreach(sAID, dom.GetObject(ID_SERVICES).EnumIDs()) {
114570
+ object oAlarm = dom.GetObject(sAID);
114571
+ if (oAlarm) {
114572
+ if (oAlarm.IsTypeOf(OT_ALARMDP)) {
114573
+ if (oAlarm.AlState() == asOncoming) {
114574
+ string sAddr = "";
114575
+ string sType = "";
114576
+ string sAck = "true";
114577
+ object oLast = dom.GetObject(oAlarm.LastTriggerID());
114578
+ if (oLast) {
114579
+ sType = oLast.HSSID();
114580
+ object oChn = dom.GetObject(oLast.Channel());
114581
+ if (oChn) {
114582
+ sAddr = oChn.Address();
114583
+ }
114584
+ }
114585
+ object oTrig = dom.GetObject(oAlarm.AlTriggerDP());
114586
+ if (oTrig) {
114587
+ if (!(oTrig.Operations() & OPERATION_WRITE)) {
114588
+ sAck = "false";
114589
+ }
114590
+ }
114591
+ Write(sSep);
114592
+ Write("{\\"id\\":");
114593
+ Write(oAlarm.ID());
114594
+ Write(",\\"name\\":\\"");
114595
+ Write(oAlarm.Name().UriEncode());
114596
+ Write("\\",\\"address\\":\\"");
114597
+ Write(sAddr);
114598
+ Write("\\",\\"type\\":\\"");
114599
+ Write(sType);
114600
+ Write("\\",\\"timestamp\\":");
114601
+ Write(oAlarm.AlOccurrenceTime().ToInteger());
114602
+ Write(",\\"acknowledgeable\\":");
114603
+ Write(sAck);
114604
+ Write("}");
114605
+ sSep = ",";
114606
+ }
114607
+ }
114608
+ }
114609
+ }
114610
+ Write("]");`;
114611
+ var ACKNOWLEDGE_SERVICE_MESSAGE = `object oAlarm = dom.GetObject(##id##);
114612
+ if (oAlarm) {
114613
+ oAlarm.AlReceipt();
114614
+ Write("{\\"ok\\":true}");
114615
+ } else {
114616
+ Write("{\\"ok\\":false}");
114617
+ }`;
114618
+ var GET_CCU_PLATFORM = `string sRelease;
114619
+ string sHost;
114620
+ string sErr;
114621
+ system.Exec("cat /VERSION", &sRelease, &sErr);
114622
+ system.Exec("hostname", &sHost, &sErr);
114623
+ Write("{\\"release\\":\\"");
114624
+ Write(sRelease.UriEncode());
114625
+ Write("\\",\\"host\\":\\"");
114626
+ Write(sHost.UriEncode());
114627
+ Write("\\"}");`;
114628
+ var GROUP_CREATE_PATH = "/pages/jpages/group/create";
114629
+ var GROUP_SAVE_PATH = "/pages/jpages/group/save";
114630
+ var GROUP_DELETE_PATH = "/pages/jpages/group/delete";
114631
+ var DRAFT_GROUP_ID = /self\.groupId\s*=\s*(\d+)/;
114632
+ var KNOWN_GROUP_TYPES = ["hmip.heating.group", "HomeMatic.heating"];
114633
+ var CHANNEL_ADDRESS = /^[0-9A-Za-z_-]+:\d+$/;
114634
+ var MAX_GROUP_NAME = 64;
114635
+ var DEFAULT_INSTALL_SECONDS = 60;
114636
+ var MAX_INSTALL_SECONDS = 3600;
114637
+ var SERVICE_MESSAGE_ID = /^\d+$/;
114638
+ function isRecord(value) {
114639
+ return value !== null && typeof value === "object" && !Array.isArray(value);
114640
+ }
114641
+ function asRecordArray(value) {
114642
+ return Array.isArray(value) ? value.filter(isRecord) : [];
114643
+ }
114644
+ function strOrNull(value) {
114645
+ if (typeof value === "string") return value === "" ? null : value;
114646
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
114647
+ return null;
114648
+ }
114649
+ function numOrNull(value) {
114650
+ if (value === null || value === void 0 || value === "") return null;
114651
+ const num = typeof value === "number" ? value : Number(value);
114652
+ return Number.isFinite(num) ? num : null;
114653
+ }
114654
+ var INTERFACES_BY_NAME = new Map(Object.values(Interface).map((iface) => [iface.toString(), iface]));
114655
+ function toInterface(name) {
114656
+ const match = INTERFACES_BY_NAME.get(name);
114657
+ if (match === void 0) throw new ValidationError(`Unknown interface "${name}".`);
114658
+ return match;
114659
+ }
114660
+ function toGateway(info) {
114661
+ return { ...info };
114662
+ }
114663
+ function decodedOrNull(value) {
114664
+ if (typeof value !== "string") return null;
114665
+ const text = decodeRegaName(value).trim();
114666
+ return text === "" ? null : text;
114667
+ }
114668
+ function parseKeyValueLines(text) {
114669
+ const out = /* @__PURE__ */ new Map();
114670
+ for (const line of text.split(/\r?\n/)) {
114671
+ const eq = line.indexOf("=");
114672
+ if (eq > 0) out.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
114673
+ }
114674
+ return out;
114675
+ }
114676
+ var OPENCCU_PRODUCTS = /* @__PURE__ */ new Set([
114677
+ "raspberrymatic",
114678
+ "openccu",
114679
+ "ova"
114680
+ ]);
114681
+ function productFamily(raw) {
114682
+ if (raw === null) return "unknown";
114683
+ const key = raw.toLowerCase();
114684
+ if (key === "ccu3") return "ccu3";
114685
+ return OPENCCU_PRODUCTS.has(key) ? "openccu" : "unknown";
114686
+ }
114687
+ var CcuManagement = class {
114688
+ json;
114689
+ webUi;
114690
+ getSessionId;
114691
+ interfaceClient;
114692
+ configuredInterfaces;
114693
+ constructor(options) {
114694
+ this.json = options.json;
114695
+ this.webUi = options.webUi;
114696
+ this.getSessionId = options.getSessionId;
114697
+ this.interfaceClient = options.interfaceClient;
114698
+ this.configuredInterfaces = options.configuredInterfaces;
114699
+ }
114700
+ /**
114701
+ * Every interface the CCU offers (JSON-RPC `Interface.listInterfaces`), with
114702
+ * connection state, duty cycle and, for BidCos, the radio gateways. Without a
114703
+ * JSON-RPC client only the configured interfaces are listed and every probe
114704
+ * result is `null`.
114705
+ */
114706
+ async listInterfaces() {
114707
+ const offered = await this.offeredInterfaces();
114708
+ const gatewaysByName = /* @__PURE__ */ new Map();
114709
+ for (const entry of offered) {
114710
+ const iface = INTERFACES_BY_NAME.get(entry.name);
114711
+ const client = iface === void 0 ? void 0 : this.interfaceClient(iface);
114712
+ if (client === void 0 || iface !== "BidCos-RF" && iface !== "HmIP-RF") continue;
114713
+ const gateways = await this.tryOrNull(() => client.listBidcosInterfaces());
114714
+ if (gateways !== null) gatewaysByName.set(entry.name, gateways.map(toGateway));
114715
+ }
114716
+ const fallbackDuty = offered.some((e) => e.name === "BidCos-RF".toString() && (gatewaysByName.get(e.name) ?? []).every((g) => g.dutyCycle === null)) ? await this.ownRadioDutyCycle() : null;
114717
+ const result = [];
114718
+ for (const entry of offered) {
114719
+ const gateways = gatewaysByName.get(entry.name) ?? [];
114720
+ const primary = gateways.find((g) => g.isDefault) ?? gateways[0];
114721
+ const connected = primary?.connected ?? await this.isPresent(entry.name);
114722
+ const dutyCycle = primary?.dutyCycle ?? (entry.name === "BidCos-RF".toString() ? fallbackDuty : null);
114723
+ result.push({
114724
+ name: entry.name,
114725
+ port: entry.port,
114726
+ info: entry.info,
114727
+ connected,
114728
+ dutyCycle,
114729
+ firmware: primary?.firmware ?? null,
114730
+ type: primary?.type ?? null,
114731
+ gateways
114732
+ });
114733
+ }
114734
+ return result;
114735
+ }
114736
+ /** Open (default 60 s) or close the pairing window of a configured interface. */
114737
+ async setInstallMode(request) {
114738
+ const client = this.requireClient(request.interface);
114739
+ if (!request.on) {
114740
+ await client.setInstallMode({ on: false });
114741
+ return;
114742
+ }
114743
+ const seconds = request.seconds ?? DEFAULT_INSTALL_SECONDS;
114744
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_INSTALL_SECONDS) throw new ValidationError(`seconds must be an integer between 1 and ${MAX_INSTALL_SECONDS}, got ${String(seconds)}.`);
114745
+ const call = request.mode === void 0 ? {
114746
+ on: true,
114747
+ seconds
114748
+ } : {
114749
+ on: true,
114750
+ seconds,
114751
+ mode: request.mode === "secure" ? 2 : 1
114752
+ };
114753
+ await client.setInstallMode(call);
114754
+ }
114755
+ /** Seconds left of the pairing window; `null` when the CCU's answer was unusable. */
114756
+ async getInstallMode(interfaceName) {
114757
+ return this.requireClient(interfaceName).getInstallModeRemaining();
114758
+ }
114759
+ /** Every active service message (ReGa listing of `ID_SERVICES`). */
114760
+ async serviceMessages() {
114761
+ return asRecordArray(await this.rega("ReGa.runScript(serviceMessages)", GET_SERVICE_MESSAGES)).flatMap((entry) => {
114762
+ const id = strOrNull(entry["id"]);
114763
+ if (id === null) return [];
114764
+ const address = strOrNull(entry["address"]);
114765
+ const colon = address === null ? -1 : address.lastIndexOf(":");
114766
+ const device = address === null ? null : colon === -1 ? address : address.slice(0, colon);
114767
+ const channel = address === null || colon === -1 ? null : numOrNull(address.slice(colon + 1));
114768
+ const seconds = numOrNull(entry["timestamp"]);
114769
+ return [{
114770
+ id,
114771
+ name: decodedOrNull(entry["name"]),
114772
+ address: device,
114773
+ channel,
114774
+ type: strOrNull(entry["type"]),
114775
+ timestamp: seconds === null || seconds <= 0 ? null : seconds * 1e3,
114776
+ acknowledgeable: entry["acknowledgeable"] === true
114777
+ }];
114778
+ });
114779
+ }
114780
+ /** Acknowledge one service message by its id. */
114781
+ async acknowledgeServiceMessage(id) {
114782
+ if (!SERVICE_MESSAGE_ID.test(id)) throw new ValidationError(`Invalid service message id "${id}".`);
114783
+ const raw = await this.rega("ReGa.runScript(acknowledgeServiceMessage)", ACKNOWLEDGE_SERVICE_MESSAGE, { id });
114784
+ if (!isRecord(raw) || raw["ok"] !== true) throw new ValidationError(`Unknown service message "${id}".`);
114785
+ }
114786
+ /** Version, product, serial and hostname; each `null` when unanswered. */
114787
+ async ccuInfo() {
114788
+ const [version, serial, platform] = await Promise.allSettled([
114789
+ this.call(JsonRpcMethod.CCU_GET_VERSION),
114790
+ this.call(JsonRpcMethod.CCU_GET_SERIAL),
114791
+ this.rega("ReGa.runScript(ccuPlatform)", GET_CCU_PLATFORM)
114792
+ ]);
114793
+ if (version.status === "rejected" && serial.status === "rejected" && platform.status === "rejected") throw version.reason instanceof CcuManagementError ? version.reason : new CcuManagementError(JsonRpcMethod.CCU_GET_VERSION, version.reason);
114794
+ const release = platform.status === "fulfilled" && isRecord(platform.value) ? parseKeyValueLines(decodeRegaName(releaseText(platform.value))) : /* @__PURE__ */ new Map();
114795
+ const hostname = platform.status === "fulfilled" && isRecord(platform.value) ? decodedOrNull(platform.value["host"]) : null;
114796
+ const product = strOrNull(release.get("PRODUCT"));
114797
+ return {
114798
+ version: version.status === "fulfilled" ? strOrNull(version.value) : null,
114799
+ product,
114800
+ productFamily: productFamily(product),
114801
+ platform: strOrNull(release.get("PLATFORM")),
114802
+ serial: serial.status === "fulfilled" ? strOrNull(serial.value) : null,
114803
+ hostname
114804
+ };
114805
+ }
114806
+ /**
114807
+ * The heating groups of the CCU ("Gruppen"), read from `CCU.getHeatingGroupList`
114808
+ * (the HMServer's `/etc/config/groups.gson`). `[]` when the CCU has no groups
114809
+ * file. Writes (`createGroup` / `updateGroup` / `deleteGroup`) go through the
114810
+ * HMServer's WebUI pages instead, see the group-writes section below.
114811
+ */
114812
+ async groups() {
114813
+ const raw = await this.call(JsonRpcMethod.CCU_GET_HEATING_GROUP_LIST);
114814
+ if (typeof raw !== "string" || raw.trim() === "") return [];
114815
+ let parsed;
114816
+ try {
114817
+ parsed = JSON.parse(raw);
114818
+ } catch (err) {
114819
+ throw new CcuManagementError(JsonRpcMethod.CCU_GET_HEATING_GROUP_LIST, err);
114820
+ }
114821
+ if (!isRecord(parsed)) return [];
114822
+ return Object.keys(parsed).sort().flatMap((key) => asRecordArray(parsed[key])).flatMap((entry) => {
114823
+ const group = toGroup(entry);
114824
+ return group === null ? [] : [group];
114825
+ });
114826
+ }
114827
+ /** Create a group; resolves with the group as read back from the CCU. */
114828
+ async createGroup(input) {
114829
+ const name = validateGroupName(input.name);
114830
+ const members = validateMembers(input.members);
114831
+ const before = await this.groups();
114832
+ this.validateType(input.type, before);
114833
+ const { webUi, sid } = await this.requireWebUi("group/create");
114834
+ const page = checkWebUiResponse(GROUP_CREATE_PATH, await webUi.get(GROUP_CREATE_PATH, sid));
114835
+ const draft = DRAFT_GROUP_ID.exec(page);
114836
+ if (draft === null || draft[1] === void 0) throw new CcuManagementError(GROUP_CREATE_PATH, new ClientError("no group id in the page"));
114837
+ const draftId = Number(draft[1]);
114838
+ const id = numOrNull(await this.saveGroup(webUi, sid, {
114839
+ groupId: draftId,
114840
+ groupName: jsEscape(name),
114841
+ groupTypeId: input.type,
114842
+ forbidSingleOperation: false,
114843
+ assignedDevicesIds: members,
114844
+ isNewGroup: true,
114845
+ groupDeviceName: `${name} `
114846
+ })) ?? draftId;
114847
+ await this.flagMembers(members, "true");
114848
+ return this.verifyPresent(id, {
114849
+ name,
114850
+ type: input.type,
114851
+ members
114852
+ });
114853
+ }
114854
+ /** Change a group's name and/or member list; resolves with the group read back. */
114855
+ async updateGroup(id, patch) {
114856
+ if (patch.name === void 0 && patch.members === void 0) throw new ValidationError("updateGroup needs a name and/or members.");
114857
+ const name = patch.name === void 0 ? void 0 : validateGroupName(patch.name);
114858
+ const members = patch.members === void 0 ? void 0 : validateMembers(patch.members);
114859
+ const current = await this.requireGroup(id);
114860
+ const { webUi, sid } = await this.requireWebUi("group/save");
114861
+ const nextName = name ?? current.name;
114862
+ const nextMembers = members ?? current.members.map((m) => m.address);
114863
+ await this.saveGroup(webUi, sid, {
114864
+ groupId: id,
114865
+ groupName: jsEscape(nextName),
114866
+ groupTypeId: current.type,
114867
+ forbidSingleOperation: current.forbidSingleOperation,
114868
+ assignedDevicesIds: nextMembers,
114869
+ isNewGroup: false,
114870
+ groupDeviceName: current.deviceAddress === null ? nextName : `${nextName} ${virtualDeviceSerial(id)}`
114871
+ });
114872
+ await this.flagMembers(nextMembers, "true");
114873
+ const kept = new Set(nextMembers);
114874
+ await this.flagMembers(current.members.map((m) => m.address).filter((a) => !kept.has(a)), "false");
114875
+ return this.verifyPresent(id, {
114876
+ name: nextName,
114877
+ type: current.type,
114878
+ members: nextMembers
114879
+ });
114880
+ }
114881
+ /** Delete a group; verified by the group being gone from the read-back. */
114882
+ async deleteGroup(id) {
114883
+ const current = await this.requireGroup(id);
114884
+ const { webUi, sid } = await this.requireWebUi("group/delete");
114885
+ checkWebUiResponse(GROUP_DELETE_PATH, await webUi.post(GROUP_DELETE_PATH, sid, JSON.stringify({ groupId: id })));
114886
+ await this.flagMembers(current.members.map((m) => m.address), "false");
114887
+ if ((await this.groups()).some((g) => g.id === id)) throw new GroupWriteVerificationError(`Group ${id} is still listed after deleteGroup.`);
114888
+ }
114889
+ async requireGroup(id) {
114890
+ if (!Number.isInteger(id)) throw new ValidationError(`Invalid group id ${String(id)}.`);
114891
+ const found = (await this.groups()).find((g) => g.id === id);
114892
+ if (found === void 0) throw new ValidationError(`Unknown group ${id}.`);
114893
+ return found;
114894
+ }
114895
+ validateType(type, existing) {
114896
+ const known = /* @__PURE__ */ new Set([...KNOWN_GROUP_TYPES, ...existing.map((g) => g.type)]);
114897
+ if (!known.has(type)) throw new ValidationError(`Unknown group type "${type}" (known: ${[...known].join(", ")}).`);
114898
+ }
114899
+ async requireWebUi(label) {
114900
+ if (this.webUi === void 0) throw new ValidationError(`${label} needs WebUI credentials (HMServer pages); none are configured.`);
114901
+ const sid = await this.getSessionId();
114902
+ if (sid === void 0) throw new ValidationError(`${label} needs a WebUI session.`);
114903
+ return {
114904
+ webUi: this.webUi,
114905
+ sid
114906
+ };
114907
+ }
114908
+ /** POST `group/save`; returns the response `content` (the group id as text). */
114909
+ async saveGroup(webUi, sid, payload) {
114910
+ return checkWebUiResponse(GROUP_SAVE_PATH, await webUi.post(GROUP_SAVE_PATH, sid, JSON.stringify(payload)));
114911
+ }
114912
+ /**
114913
+ * The WebUI marks every device it puts in / takes out of a heating group with
114914
+ * `Interface.setMetadata inHeatingGroup` (its pages use the flag to hide
114915
+ * grouped devices). Best effort: the hint is cosmetic and the group itself is
114916
+ * verified separately.
114917
+ */
114918
+ async flagMembers(addresses, value) {
114919
+ const devices = [...new Set(addresses.map((a) => a.slice(0, a.lastIndexOf(":"))))];
114920
+ for (const address of devices) try {
114921
+ const objectId = await this.call(JsonRpcMethod.DEVICE_GET_REGA_ID_BY_ADDRESS, { address });
114922
+ if (typeof objectId !== "string" || objectId === "" || objectId === "noDeviceFound") continue;
114923
+ await this.call(JsonRpcMethod.INTERFACE_SET_METADATA, {
114924
+ objectId,
114925
+ dataId: "inHeatingGroup",
114926
+ value
114927
+ });
114928
+ } catch {}
114929
+ }
114930
+ async verifyPresent(id, want) {
114931
+ const found = (await this.groups()).find((g) => g.id === id);
114932
+ if (found === void 0) throw new GroupWriteVerificationError(`Group ${id} is not listed after the write.`);
114933
+ const have = found.members.map((m) => m.address).sort();
114934
+ const expected = [...want.members].sort();
114935
+ const sameMembers = have.length === expected.length && have.every((a, i) => a === expected[i]);
114936
+ if (found.name !== want.name || found.type !== want.type || !sameMembers) throw new GroupWriteVerificationError(`Group ${id} reads back as "${found.name}" (${found.type}, ${have.length} members), expected "${want.name}" (${want.type}, ${expected.length} members).`);
114937
+ return found;
114938
+ }
114939
+ requireClient(interfaceName) {
114940
+ const iface = toInterface(interfaceName);
114941
+ const client = this.interfaceClient(iface);
114942
+ if (client === void 0) throw new ValidationError(`Interface "${interfaceName}" is not configured.`);
114943
+ return client;
114944
+ }
114945
+ requireJson(method) {
114946
+ if (this.json === void 0) throw new ValidationError(`${method} needs WebUI credentials (JSON-RPC); none are configured.`);
114947
+ return this.json;
114948
+ }
114949
+ /** A session-bound JSON-RPC call whose failure names the method. */
114950
+ async call(method, params) {
114951
+ const json = this.requireJson(method);
114952
+ try {
114953
+ const sessionId = await this.getSessionId();
114954
+ return await json.post(method, params, sessionId !== void 0 ? { sessionId } : void 0);
114955
+ } catch (err) {
114956
+ throw err instanceof ValidationError ? err : new CcuManagementError(method, err);
114957
+ }
114958
+ }
114959
+ async rega(label, script, params) {
114960
+ const json = this.requireJson(label);
114961
+ try {
114962
+ return await runScript(json, await this.getSessionId(), script, params);
114963
+ } catch (err) {
114964
+ throw err instanceof ValidationError ? err : new CcuManagementError(label, err);
114965
+ }
114966
+ }
114967
+ async tryOrNull(fn) {
114968
+ try {
114969
+ return await fn();
114970
+ } catch {
114971
+ return null;
114972
+ }
114973
+ }
114974
+ async offeredInterfaces() {
114975
+ if (this.json === void 0) return this.configuredInterfaces().map((iface) => ({
114976
+ name: iface,
114977
+ port: null,
114978
+ info: null
114979
+ }));
114980
+ return asRecordArray(await this.call(JsonRpcMethod.INTERFACE_LIST_INTERFACES)).flatMap((entry) => {
114981
+ const name = strOrNull(entry["name"]);
114982
+ return name === null ? [] : [{
114983
+ name,
114984
+ port: numOrNull(entry["port"]),
114985
+ info: strOrNull(entry["info"])
114986
+ }];
114987
+ });
114988
+ }
114989
+ async isPresent(name) {
114990
+ if (this.json === void 0) return null;
114991
+ const answer = await this.tryOrNull(() => this.call(JsonRpcMethod.INTERFACE_IS_PRESENT, { interface: name }));
114992
+ if (typeof answer === "boolean") return answer;
114993
+ if (answer === "true") return true;
114994
+ if (answer === "false") return false;
114995
+ return null;
114996
+ }
114997
+ /**
114998
+ * Fallback when `listBidcosInterfaces` gave no answer: `Interface.getDutyCycle`
114999
+ * (RaspberryMatic / OpenCCU) reports the CCU's own radio module as ONE entry
115000
+ * with string numbers (`"7"`). It cannot be attributed to HmIP, so it only
115001
+ * ever stands in for BidCos-RF; non-numeric or absent is `null`.
115002
+ */
115003
+ async ownRadioDutyCycle() {
115004
+ if (this.json === void 0) return null;
115005
+ const entries = asRecordArray(await this.tryOrNull(() => this.call(JsonRpcMethod.INTERFACE_GET_DUTY_CYCLE)));
115006
+ return entries.length === 1 ? numOrNull(entries[0]?.["dutyCycle"]) : null;
115007
+ }
115008
+ };
115009
+ function releaseText(value) {
115010
+ const release = value["release"];
115011
+ return typeof release === "string" ? release : "";
115012
+ }
115013
+ var DEVICE_ADDRESS_SUFFIX = /\b(INT\d{7})\s*$/;
115014
+ function toGroup(entry) {
115015
+ const id = numOrNull(entry["id"]);
115016
+ if (id === null) return null;
115017
+ const props = isRecord(entry["groupProperties"]) ? entry["groupProperties"] : {};
115018
+ const groupType = isRecord(entry["groupType"]) ? entry["groupType"] : {};
115019
+ const deviceName = strOrNull(props["GROUP_DEVICE_NAME"]);
115020
+ const deviceMatch = deviceName === null ? null : DEVICE_ADDRESS_SUFFIX.exec(deviceName);
115021
+ const members = asRecordArray(entry["groupMembers"]).flatMap((m) => {
115022
+ const address = strOrNull(m["id"]);
115023
+ if (address === null) return [];
115024
+ return [{
115025
+ address,
115026
+ type: (isRecord(m["memberType"]) ? strOrNull(m["memberType"]["id"]) : null) ?? ""
115027
+ }];
115028
+ });
115029
+ return {
115030
+ id,
115031
+ name: (strOrNull(props["NAME"]) ?? "").trim(),
115032
+ type: strOrNull(groupType["id"]) ?? "",
115033
+ typeLabel: strOrNull(groupType["label"]),
115034
+ deviceAddress: deviceMatch === null ? null : deviceMatch[1] ?? null,
115035
+ forbidSingleOperation: props["FORBID_SINGLE_OPERATION"] === true,
115036
+ members
115037
+ };
115038
+ }
115039
+ function virtualDeviceSerial(id) {
115040
+ return `INT${String(id).padStart(7, "0")}`;
115041
+ }
115042
+ function validateGroupName(raw) {
115043
+ const name = raw.replace(/"/g, "'").trim();
115044
+ if (name === "" || name.length > MAX_GROUP_NAME) throw new ValidationError(`Group name must be 1..${MAX_GROUP_NAME} characters.`);
115045
+ return name;
115046
+ }
115047
+ function validateMembers(members) {
115048
+ for (const address of members) if (!CHANNEL_ADDRESS.test(address)) throw new ValidationError(`Invalid member channel address "${address}".`);
115049
+ return [...new Set(members)];
115050
+ }
115051
+ function checkWebUiResponse(path, text) {
115052
+ let parsed;
115053
+ try {
115054
+ parsed = JSON.parse(text);
115055
+ } catch {
115056
+ throw new CcuManagementError(path, new ClientError("response was not JSON"));
115057
+ }
115058
+ if (!isRecord(parsed) || parsed["isSuccessful"] !== true) {
115059
+ const code = isRecord(parsed) ? strOrNull(parsed["errorCode"]) : null;
115060
+ throw new CcuManagementError(path, new ClientError(code === "42" ? "session expired (errorCode 42)" : `refused (errorCode ${code ?? "none"})`));
115061
+ }
115062
+ const content = parsed["content"];
115063
+ return typeof content === "string" || typeof content === "number" ? String(content) : "";
115064
+ }
113511
115065
  var FAULT_METHOD_NOT_FOUND = -32601;
113512
115066
  var FAULT_INTERNAL_ERROR = -32603;
113513
115067
  var MethodNotFoundError = class extends BaseHomematicError {};
@@ -114415,128 +115969,6 @@ function asString(value) {
114415
115969
  if (typeof value === "string" && value !== "") return value;
114416
115970
  if (typeof value === "number") return String(value);
114417
115971
  }
114418
- function escapeRegaString(s) {
114419
- return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
114420
- }
114421
- var CONTROL_CHARS = /[\u0000-\u001F]/g;
114422
- function sanitizeJsonControlChars(s) {
114423
- return s.replace(CONTROL_CHARS, "");
114424
- }
114425
- function decodeRegaName(encoded) {
114426
- const bytes = [];
114427
- for (let i = 0; i < encoded.length; i += 1) {
114428
- const ch = encoded[i];
114429
- if (ch === "%" && i + 2 < encoded.length) {
114430
- const hex = encoded.slice(i + 1, i + 3);
114431
- const byte = Number.parseInt(hex, 16);
114432
- if (!Number.isNaN(byte) && /^[0-9a-fA-F]{2}$/.test(hex)) {
114433
- bytes.push(byte);
114434
- i += 2;
114435
- continue;
114436
- }
114437
- }
114438
- if (ch === "+") {
114439
- bytes.push(32);
114440
- continue;
114441
- }
114442
- bytes.push(ch.charCodeAt(0) & 255);
114443
- }
114444
- return Buffer.from(bytes).toString("latin1");
114445
- }
114446
- function substitute(body, params) {
114447
- let out = body;
114448
- for (const [key, value] of Object.entries(params)) {
114449
- const escaped = escapeRegaString(String(value));
114450
- out = out.split(`##${key}##`).join(escaped);
114451
- }
114452
- return out;
114453
- }
114454
- async function runScript(client, sessionId, body, params) {
114455
- const script = params ? substitute(body, params) : body;
114456
- const result = await client.post(JsonRpcMethod.REGA_RUN_SCRIPT, { script }, sessionId !== void 0 ? { sessionId } : void 0);
114457
- if (result === void 0 || result === null) throw new ClientError("ReGa.runScript returned no result");
114458
- if (typeof result === "string") return JSON.parse(sanitizeJsonControlChars(result));
114459
- return result;
114460
- }
114461
- var SET_SYSTEM_VARIABLE = `object sv = dom.GetObject("##name##");
114462
- if (sv) {
114463
- sv.State("##value##");
114464
- }`;
114465
- var SET_PROGRAM_STATE = `object prog = dom.GetObject(##id##);
114466
- if (prog) {
114467
- prog.Active(##state##);
114468
- }`;
114469
- var SYSVAR_DESCRIPTIONS = `string sSysVarId;
114470
- string sSep = "";
114471
- WriteLine("[");
114472
- foreach(sSysVarId, dom.GetObject(ID_SYSTEM_VARIABLES).EnumUsedIDs()) {
114473
- object oSysVar = dom.GetObject(sSysVarId);
114474
- Write(sSep);
114475
- Write("{");
114476
- Write("\\"id\\":");
114477
- Write(oSysVar.ID());
114478
- Write(",\\"description\\":\\"");
114479
- Write(oSysVar.DPInfo().UriEncode());
114480
- Write("\\"}");
114481
- sSep = ",";
114482
- }
114483
- WriteLine("]");`;
114484
- var GET_ROOMS_FUNCTIONS = `string sChnId;
114485
- string sRoomId;
114486
- string sFuncId;
114487
- string sChnSepR = "";
114488
- string sChnSepF = "";
114489
- Write("{\\"rooms\\":{");
114490
- foreach(sChnId, dom.GetObject(ID_CHANNELS).EnumUsedIDs()) {
114491
- object oChn = dom.GetObject(sChnId);
114492
- if (oChn) {
114493
- string sAddr = oChn.Address();
114494
- object oRooms = oChn.ChnRoom();
114495
- if (oRooms && oRooms.Count() > 0) {
114496
- Write(sChnSepR);
114497
- Write("\\"");
114498
- Write(sAddr);
114499
- Write("\\":[");
114500
- string sRoomSep = "";
114501
- foreach(sRoomId, oRooms) {
114502
- object oRoom = dom.GetObject(sRoomId);
114503
- Write(sRoomSep);
114504
- Write("\\"");
114505
- Write(oRoom.Name().UriEncode());
114506
- Write("\\"");
114507
- sRoomSep = ",";
114508
- }
114509
- Write("]");
114510
- sChnSepR = ",";
114511
- }
114512
- }
114513
- }
114514
- Write("},\\"functions\\":{");
114515
- foreach(sChnId, dom.GetObject(ID_CHANNELS).EnumUsedIDs()) {
114516
- object oChn = dom.GetObject(sChnId);
114517
- if (oChn) {
114518
- string sAddr = oChn.Address();
114519
- object oFuncs = oChn.ChnFunction();
114520
- if (oFuncs && oFuncs.Count() > 0) {
114521
- Write(sChnSepF);
114522
- Write("\\"");
114523
- Write(sAddr);
114524
- Write("\\":[");
114525
- string sFuncSep = "";
114526
- foreach(sFuncId, oFuncs) {
114527
- object oFunc = dom.GetObject(sFuncId);
114528
- Write(sFuncSep);
114529
- Write("\\"");
114530
- Write(oFunc.Name().UriEncode());
114531
- Write("\\"");
114532
- sFuncSep = ",";
114533
- }
114534
- Write("]");
114535
- sChnSepF = ",";
114536
- }
114537
- }
114538
- }
114539
- Write("}}");`;
114540
115972
  var HubValueType = /* @__PURE__ */ ((HubValueType2) => {
114541
115973
  HubValueType2["ALARM"] = "ALARM";
114542
115974
  HubValueType2["FLOAT"] = "FLOAT";
@@ -114575,7 +116007,7 @@ function parseSysVarValue(type, raw) {
114575
116007
  }
114576
116008
  }
114577
116009
  var WRITABLE_MARKER = "HAHM";
114578
- function isRecord(value) {
116010
+ function isRecord2(value) {
114579
116011
  return value !== null && typeof value === "object";
114580
116012
  }
114581
116013
  function asString2(value) {
@@ -114594,9 +116026,9 @@ function asOptionalNumber(value) {
114594
116026
  const num = Number(value);
114595
116027
  return Number.isFinite(num) ? num : void 0;
114596
116028
  }
114597
- function asRecordArray(value) {
116029
+ function asRecordArray2(value) {
114598
116030
  if (!Array.isArray(value)) return [];
114599
- return value.filter(isRecord);
116031
+ return value.filter(isRecord2);
114600
116032
  }
114601
116033
  var HubFetcher = class {
114602
116034
  client;
@@ -114615,7 +116047,7 @@ var HubFetcher = class {
114615
116047
  */
114616
116048
  async fetchSystemVariables() {
114617
116049
  const sid = this.sid();
114618
- const entries = asRecordArray(await this.client.post(JsonRpcMethod.SYSVAR_GET_ALL, void 0, sidOpts(sid)));
116050
+ const entries = asRecordArray2(await this.client.post(JsonRpcMethod.SYSVAR_GET_ALL, void 0, sidOpts(sid)));
114619
116051
  const descriptions = await this.fetchSysVarDescriptions();
114620
116052
  return entries.map((entry) => this.toSystemVariable(entry, descriptions));
114621
116053
  }
@@ -114657,7 +116089,7 @@ var HubFetcher = class {
114657
116089
  /** Fetch all programs, coercing flags to booleans. */
114658
116090
  async fetchPrograms() {
114659
116091
  const sid = this.sid();
114660
- return asRecordArray(await this.client.post(JsonRpcMethod.PROGRAM_GET_ALL, void 0, sidOpts(sid))).map((entry) => {
116092
+ return asRecordArray2(await this.client.post(JsonRpcMethod.PROGRAM_GET_ALL, void 0, sidOpts(sid))).map((entry) => {
114661
116093
  const lastExecuteTime = entry.lastExecuteTime;
114662
116094
  return {
114663
116095
  id: asString2(entry.id),
@@ -114689,8 +116121,8 @@ var HubFetcher = class {
114689
116121
  const sid = this.sid();
114690
116122
  const parsed = await runScript(this.client, sid, GET_ROOMS_FUNCTIONS);
114691
116123
  return {
114692
- rooms: toDecodedMap(isRecord(parsed) ? parsed.rooms : void 0),
114693
- functions: toDecodedMap(isRecord(parsed) ? parsed.functions : void 0)
116124
+ rooms: toDecodedMap(isRecord2(parsed) ? parsed.rooms : void 0),
116125
+ functions: toDecodedMap(isRecord2(parsed) ? parsed.functions : void 0)
114694
116126
  };
114695
116127
  }
114696
116128
  /** Build the `SYSVAR_DESCRIPTIONS` map: sysvar id → description text. */
@@ -114699,7 +116131,7 @@ var HubFetcher = class {
114699
116131
  const out = /* @__PURE__ */ new Map();
114700
116132
  try {
114701
116133
  const parsed = await runScript(this.client, sid, SYSVAR_DESCRIPTIONS);
114702
- for (const entry of asRecordArray(parsed)) {
116134
+ for (const entry of asRecordArray2(parsed)) {
114703
116135
  const id = asString2(entry.id);
114704
116136
  if (id !== "") out.set(id, asString2(entry.description));
114705
116137
  }
@@ -114736,7 +116168,7 @@ function sidOpts(sid) {
114736
116168
  }
114737
116169
  function toDecodedMap(value) {
114738
116170
  const map = /* @__PURE__ */ new Map();
114739
- if (!isRecord(value)) return map;
116171
+ if (!isRecord2(value)) return map;
114740
116172
  for (const [addr, names] of Object.entries(value)) if (Array.isArray(names)) map.set(addr, names.filter((n) => typeof n === "string").map((n) => decodeRegaName(n)));
114741
116173
  return map;
114742
116174
  }
@@ -115265,6 +116697,7 @@ var CentralUnit = class {
115265
116697
  ifaceById = /* @__PURE__ */ new Map();
115266
116698
  jsonClient;
115267
116699
  session;
116700
+ webUi;
115268
116701
  callbackServer;
115269
116702
  router;
115270
116703
  recovery;
@@ -115332,6 +116765,30 @@ var CentralUnit = class {
115332
116765
  });
115333
116766
  }
115334
116767
  /**
116768
+ * Build the {@link CcuManagement} bound to this central's interface clients and
116769
+ * (when credentialed) its JSON-RPC client. The session id is ensured per call
116770
+ * through the {@link SessionManager}, so renews apply. INTERNAL: the facade
116771
+ * exposes it; it is not part of the published surface.
116772
+ */
116773
+ getManagement() {
116774
+ return new CcuManagement({
116775
+ json: this.jsonClient,
116776
+ webUi: this.webUi,
116777
+ getSessionId: () => this.session !== void 0 ? this.session.ensureSession() : Promise.resolve(void 0),
116778
+ interfaceClient: (iface) => {
116779
+ for (const runtime of this.runtimes.values()) if (runtime.iface === iface) return runtime.client;
116780
+ },
116781
+ configuredInterfaces: () => this.interfaces
116782
+ });
116783
+ }
116784
+ /**
116785
+ * Remove a device from the interface owning it, routing by `interfaceId`.
116786
+ * Throws if the interface is unknown.
116787
+ */
116788
+ async deleteDevice(interfaceId2, address, flags) {
116789
+ await this.clientFor(interfaceId2).deleteDevice(address, flags);
116790
+ }
116791
+ /**
115335
116792
  * Read the discovered paramset DESCRIPTION (the spec — types, ranges, flags)
115336
116793
  * for a channel + paramset key from the in-memory {@link ParamsetDescriptionCache}.
115337
116794
  * Returns `undefined` if the paramset has not been discovered. This is the
@@ -115463,10 +116920,14 @@ var CentralUnit = class {
115463
116920
  }
115464
116921
  buildJsonRpc() {
115465
116922
  if (this.credentials === void 0) return;
115466
- if (this.options.jsonClient !== void 0) this.jsonClient = this.options.jsonClient;
115467
- else {
115468
- const client = new JsonRpcClient({ url: `${this.tls ? "https" : "http"}://${this.host}` });
116923
+ if (this.options.jsonClient !== void 0) {
116924
+ this.jsonClient = this.options.jsonClient;
116925
+ this.webUi = this.options.webUiClient;
116926
+ } else {
116927
+ const scheme = this.tls ? "https" : "http";
116928
+ const client = new JsonRpcClient({ url: `${scheme}://${this.host}` });
115469
116929
  this.jsonClient = client;
116930
+ this.webUi = this.options.webUiClient ?? new WebUiHttpClient({ url: `${scheme}://${this.host}` });
115470
116931
  this.session = new SessionManager({
115471
116932
  client,
115472
116933
  username: this.credentials.username,
@@ -116944,7 +118405,7 @@ var TypedEventEmitter = class {
116944
118405
  };
116945
118406
  var DEFAULT_CENTRAL_NAME = "nodehomematic";
116946
118407
  var pendingTestInjectables;
116947
- function toInterface(name) {
118408
+ function toInterface2(name) {
116948
118409
  const match = Object.values(Interface).includes(name) ? name : void 0;
116949
118410
  if (match === void 0) throw new ValidationError(`Unknown interface "${name}".`);
116950
118411
  return match;
@@ -116954,7 +118415,7 @@ function deviceAddressOf4(channelAddress2) {
116954
118415
  return colon === -1 ? channelAddress2 : channelAddress2.slice(0, colon);
116955
118416
  }
116956
118417
  function buildCentral(options, injectables) {
116957
- const interfaces = options.interfaces.map(toInterface);
118418
+ const interfaces = options.interfaces.map(toInterface2);
116958
118419
  if (interfaces.length === 0) throw new ValidationError("At least one interface is required.");
116959
118420
  if (injectables.central !== void 0) return injectables.central;
116960
118421
  return new CentralUnit({
@@ -116987,6 +118448,8 @@ var Homematic = class {
116987
118448
  #started = false;
116988
118449
  /** Bound hub fetcher, or undefined when no JSON-RPC client is available. */
116989
118450
  #hub;
118451
+ /** CCU-management service, built on first use, cleared at stop(). */
118452
+ #management;
116990
118453
  /** Snapshot of system variables, refreshed on start / refreshHub. */
116991
118454
  #sysvars = [];
116992
118455
  /** Snapshot of programs, refreshed on start / refreshHub. */
@@ -117044,6 +118507,7 @@ var Homematic = class {
117044
118507
  this.#customEntityList = [];
117045
118508
  this.#customEntitiesByKey.clear();
117046
118509
  this.#hub = void 0;
118510
+ this.#management = void 0;
117047
118511
  this.#sysvars = [];
117048
118512
  this.#programs = [];
117049
118513
  this.#roomsByDevice.clear();
@@ -117088,6 +118552,98 @@ var Homematic = class {
117088
118552
  await this.#central.installFirmware(device.interfaceId, deviceAddress);
117089
118553
  }
117090
118554
  /**
118555
+ * Every interface the CCU offers (BidCos-RF, HmIP-RF, VirtualDevices, ...)
118556
+ * with connection state, duty cycle, firmware and type where the CCU reports
118557
+ * them. A value the CCU did not answer is `null`, never `0`. Needs a started
118558
+ * facade.
118559
+ *
118560
+ * CCU methods: JSON-RPC `Interface.listInterfaces` / `Interface.isPresent`
118561
+ * (eq-3/occu `WebUI/www/api/methods/interface/`), XML-RPC
118562
+ * `listBidcosInterfaces` on BidCos-RF, JSON-RPC `Interface.getDutyCycle`
118563
+ * (RaspberryMatic / OpenCCU only) for the HmIP access point.
118564
+ */
118565
+ async listInterfaces() {
118566
+ return this.#requireManagement().listInterfaces();
118567
+ }
118568
+ /**
118569
+ * Open (default 60 s, 1..3600) or close the pairing window of a configured
118570
+ * interface. XML-RPC `setInstallMode`; `mode: 'secure'` (BidCos only) also
118571
+ * teaches the HomeMatic Secure key.
118572
+ */
118573
+ async setInstallMode(request) {
118574
+ await this.#requireManagement().setInstallMode(request);
118575
+ }
118576
+ /** Seconds left of an interface's pairing window (`null` when the CCU's answer was unusable). XML-RPC `getInstallMode`. */
118577
+ async getInstallMode(interfaceName) {
118578
+ return this.#requireManagement().getInstallMode(interfaceName);
118579
+ }
118580
+ /**
118581
+ * Remove a device from its interface (XML-RPC `deleteDevice`). `flags` is an OR
118582
+ * of {@link HM_DELETE_FLAGS}: `RESET` (0x01, factory reset), `FORCE` (0x02,
118583
+ * remove even when unreachable), `DEFER` (0x04, remove when next reachable);
118584
+ * `0` is a plain removal. The model drops the device once the CCU reports it
118585
+ * through the `deleteDevices` callback. Throws {@link ValidationError} for an
118586
+ * unknown device or invalid flags.
118587
+ */
118588
+ async deleteDevice(deviceAddress, flags = 0) {
118589
+ this.#requireManagement();
118590
+ const device = this.#devicesByAddress.get(deviceAddress);
118591
+ if (device === void 0) throw new ValidationError(`Unknown device "${deviceAddress}".`);
118592
+ if (!Number.isInteger(flags) || flags < 0 || flags > 7) throw new ValidationError(`Invalid delete flags ${String(flags)} (expected an integer 0..7).`);
118593
+ await this.#central.deleteDevice(device.interfaceId, deviceAddress, flags);
118594
+ }
118595
+ /**
118596
+ * The CCU's active service messages (UNREACH, LOWBAT, CONFIG_PENDING,
118597
+ * STICKY_UNREACH, ...). Needs WebUI credentials. ReGa script over
118598
+ * `ReGa.runScript` (eq-3/occu `WebUI/www/rega/esp/system.fn`, `ID_SERVICES`).
118599
+ */
118600
+ async serviceMessages() {
118601
+ return this.#requireManagement().serviceMessages();
118602
+ }
118603
+ /** Acknowledge a service message by its `id` (ReGa `AlReceipt`, as the WebUI does). Needs WebUI credentials. */
118604
+ async acknowledgeServiceMessage(id) {
118605
+ await this.#requireManagement().acknowledgeServiceMessage(id);
118606
+ }
118607
+ /**
118608
+ * Version, product (CCU3 / RaspberryMatic / OpenCCU), serial and hostname.
118609
+ * Needs WebUI credentials. JSON-RPC `CCU.getVersion`, `CCU.getSerial`
118610
+ * (eq-3/occu `WebUI/www/api/methods/ccu/`) plus a ReGa read of `/VERSION`.
118611
+ */
118612
+ async ccuInfo() {
118613
+ return this.#requireManagement().ccuInfo();
118614
+ }
118615
+ /**
118616
+ * The CCU's heating groups ("Gruppen"): HmIP heating groups and HomeMatic
118617
+ * heating groups with their member channels. Writes are separate and
118618
+ * undocumented: {@link Homematic.createGroup}, `updateGroup`, `deleteGroup`.
118619
+ * JSON-RPC `CCU.getHeatingGroupList` (eq-3/occu
118620
+ * `WebUI/www/api/methods/ccu/getheatinggrouplist.tcl`). Needs WebUI credentials.
118621
+ */
118622
+ async groups() {
118623
+ return this.#requireManagement().groups();
118624
+ }
118625
+ /**
118626
+ * Create a heating group (`type` e.g. `hmip.heating.group`, `members` channel
118627
+ * addresses). Resolves with the group as read back from the CCU; a write the
118628
+ * read-back does not show rejects with `GroupWriteVerificationError`.
118629
+ *
118630
+ * UNDOCUMENTED and firmware-dependent: no CCU API exists for this, so the call
118631
+ * reproduces the WebUI's own HMServer page requests (eq-3/occu
118632
+ * `HMserver/opt/HMServer/pages/GroupEditPage.ftl`, `GroupListPage.ftl`).
118633
+ * Needs WebUI credentials.
118634
+ */
118635
+ async createGroup(input) {
118636
+ return this.#requireManagement().createGroup(input);
118637
+ }
118638
+ /** Rename a group and/or replace its member list (same caveats as {@link Homematic.createGroup}). */
118639
+ async updateGroup(id, patch) {
118640
+ return this.#requireManagement().updateGroup(id, patch);
118641
+ }
118642
+ /** Delete a group, verified by the read-back (same caveats as {@link Homematic.createGroup}). */
118643
+ async deleteGroup(id) {
118644
+ await this.#requireManagement().deleteGroup(id);
118645
+ }
118646
+ /**
117091
118647
  * The MASTER configuration parameters of a channel, with the metadata needed
117092
118648
  * to render a form. Empty array when nothing is known for the channel.
117093
118649
  */
@@ -117475,6 +119031,12 @@ var Homematic = class {
117475
119031
  if (byName !== void 0) return byName.id;
117476
119032
  throw new ValidationError(`Unknown program "${idOrName}".`);
117477
119033
  }
119034
+ /** Return the management service (built on first use), or throw before start(). */
119035
+ #requireManagement() {
119036
+ if (!this.#started) throw new ValidationError("Homematic is not started; call start() first.");
119037
+ this.#management ??= this.#central.getManagement();
119038
+ return this.#management;
119039
+ }
117478
119040
  /** Return the bound hub fetcher, or throw if none (no credentials). */
117479
119041
  #requireHub() {
117480
119042
  if (this.#hub === void 0) throw new ValidationError("Hub is unavailable (no WebUI credentials configured).");
@@ -118492,14 +120054,15 @@ var HomematicProviderAddon = class extends BaseDeviceProvider {
118492
120054
  ],
118493
120055
  customActions: homematicActions,
118494
120056
  actionHandlers: { discoverCcu: async () => {
118495
- const { proposals, summary } = await this.discoverCcus();
120057
+ const { candidates } = await this.ctx.api.deviceManager.discoverProvider.mutate({ addonId: this.ctx.id });
120058
+ const hosts = candidates.map((candidate) => ({
120059
+ host: String(candidate.prefilledConfig["host"] ?? ""),
120060
+ name: candidate.suggestedName,
120061
+ id: candidate.stableId.replace(/^homematic:/, "")
120062
+ }));
118496
120063
  return {
118497
- hosts: proposals.map((found) => ({
118498
- host: found.host,
118499
- name: found.name,
118500
- id: found.id
118501
- })),
118502
- summary
120064
+ hosts,
120065
+ summary: homematicDiscoverySummary(hosts.length, hosts.length)
118503
120066
  };
118504
120067
  } }
118505
120068
  };
@@ -118534,18 +120097,21 @@ var HomematicProviderAddon = class extends BaseDeviceProvider {
118534
120097
  async supportsManualCreation() {
118535
120098
  return false;
118536
120099
  }
118537
- /** LAN scan for CCU3 / RaspberryMatic WebUIs over the local /24 networks of this (hub-only) addon's host. */
120100
+ /** A CCU is found by the scan engine's http-probe; this addon classifies the hits. */
118538
120101
  async supportsDiscovery() {
118539
120102
  return true;
118540
120103
  }
118541
- async discoverDevices() {
118542
- const { proposals } = await this.discoverCcus();
118543
- return homematicDiscoveryCandidates(proposals).map((candidate) => ({
118544
- stableId: candidate.stableId,
118545
- type: DeviceType.Gateway,
118546
- suggestedName: candidate.suggestedName,
118547
- prefilledConfig: { ...candidate.prefilledConfig }
118548
- }));
120104
+ async getDiscoveryModes() {
120105
+ return HOMEMATIC_DISCOVERY_MODES;
120106
+ }
120107
+ async classifyDiscoveryHits(input) {
120108
+ const found = ccusFromHits(input.hits);
120109
+ const proposals = proposeHomematicCcus(found, await this.knownCcuHosts());
120110
+ this.ctx.logger.info("Homematic CCU discovery finished", { meta: {
120111
+ found: found.length,
120112
+ proposed: proposals.length
120113
+ } });
120114
+ return homematicDiscoveryCandidates(proposals);
118549
120115
  }
118550
120116
  async adoptDiscoveredDevice(_input) {
118551
120117
  throw new Error("A Homematic CCU is added from the integration form");
@@ -118556,23 +120122,6 @@ var HomematicProviderAddon = class extends BaseDeviceProvider {
118556
120122
  async onCreateDevice(_type, _config) {
118557
120123
  throw new Error("Homematic devices are adopted from the integration, not created manually");
118558
120124
  }
118559
- async discoverCcus() {
118560
- let found = [];
118561
- try {
118562
- found = await browseHomematicCcus();
118563
- } catch (err) {
118564
- this.ctx.logger.warn("Homematic CCU discovery failed", { meta: { error: errMsg(err) } });
118565
- }
118566
- const proposals = proposeHomematicCcus(found, await this.knownCcuHosts());
118567
- this.ctx.logger.info("Homematic CCU discovery finished", { meta: {
118568
- found: found.length,
118569
- proposed: proposals.length
118570
- } });
118571
- return {
118572
- proposals,
118573
- summary: homematicDiscoverySummary(found.length, proposals.length)
118574
- };
118575
- }
118576
120125
  async knownCcuHosts() {
118577
120126
  try {
118578
120127
  const all = await this.ctx.api.integrations.list.query();