@camstack/addon-provider-homematic 1.2.163 → 1.2.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -5464,7 +5464,7 @@ var ZodIssueCode = {
5464
5464
  var ZodFirstPartyTypeKind;
5465
5465
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5466
5466
  //#endregion
5467
- //#region ../types/dist/sleep-63gM_1JR.mjs
5467
+ //#region ../types/dist/sleep-CeyhrcOk.mjs
5468
5468
  /**
5469
5469
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5470
5470
  * window to float samples (D455).
@@ -7531,6 +7531,7 @@ var BaseAddon = class {
7531
7531
  * a warning and falls back rather than crashing boot.
7532
7532
  */
7533
7533
  state(key, schema, fallback) {
7534
+ this._declaredStateKeys.add(key);
7534
7535
  return createDurableState({
7535
7536
  key,
7536
7537
  schema,
@@ -7546,6 +7547,96 @@ var BaseAddon = class {
7546
7547
  } })
7547
7548
  });
7548
7549
  }
7550
+ /** Durable-state keys this process registered through {@link state}. */
7551
+ _declaredStateKeys = /* @__PURE__ */ new Set();
7552
+ /**
7553
+ * Stale-data audit: every global-settings key this addon still declares —
7554
+ * constructor defaults, settings-schema fields (layout containers included),
7555
+ * durable-state keys and the root of each `secretStatePaths` entry. Answered
7556
+ * here, in the addon's own process, so the hub keeps no copy of it.
7557
+ */
7558
+ async getDeclaredSettingsKeys() {
7559
+ return { keys: [...this.declaredKeySet()].toSorted() };
7560
+ }
7561
+ declaredKeySet() {
7562
+ const keys = new Set(Object.keys(this.defaults));
7563
+ const schema = this.globalSettingsSchema();
7564
+ if (schema) for (const section of schema.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
7565
+ for (const key of this._declaredStateKeys) keys.add(key);
7566
+ for (const path of this.secretStatePaths) {
7567
+ const root = path.split(/[.[]/, 1)[0];
7568
+ if (root) keys.add(root);
7569
+ }
7570
+ return keys;
7571
+ }
7572
+ /**
7573
+ * Stale-data audit: remove stored global-settings keys the addon no longer
7574
+ * declares, and clear the keychain values their markers held — except a value
7575
+ * a surviving key still points at. A declared or absent key is refused.
7576
+ */
7577
+ async unsetStoredSettingsKeys(keys) {
7578
+ const settings = this._ctx?.settings;
7579
+ const raw = this.rawSettings();
7580
+ if (!settings || !raw) return {
7581
+ removed: [],
7582
+ refused: keys.map((key) => ({
7583
+ key,
7584
+ reason: "no settings store"
7585
+ })),
7586
+ secretsCleared: 0
7587
+ };
7588
+ let stored;
7589
+ try {
7590
+ stored = await raw.readAddonStore();
7591
+ } catch (error) {
7592
+ const reason = `store unreadable: ${error instanceof Error ? error.message : "unknown"}`;
7593
+ return {
7594
+ removed: [],
7595
+ refused: keys.map((key) => ({
7596
+ key,
7597
+ reason
7598
+ })),
7599
+ secretsCleared: 0
7600
+ };
7601
+ }
7602
+ const declared = this.declaredKeySet();
7603
+ const removed = [];
7604
+ const refused = [];
7605
+ for (const key of keys) {
7606
+ const base = key.split("@", 1)[0] ?? key;
7607
+ if (declared.has(base)) refused.push({
7608
+ key,
7609
+ reason: "still declared by the addon"
7610
+ });
7611
+ else if (!(key in stored)) refused.push({
7612
+ key,
7613
+ reason: "not stored"
7614
+ });
7615
+ else removed.push(key);
7616
+ }
7617
+ if (removed.length === 0) return {
7618
+ removed,
7619
+ refused,
7620
+ secretsCleared: 0
7621
+ };
7622
+ const kept = /* @__PURE__ */ new Set();
7623
+ const held = /* @__PURE__ */ new Set();
7624
+ for (const [key, value] of Object.entries(stored)) {
7625
+ const target = removed.includes(key) ? held : kept;
7626
+ for (const ref of collectSecretMarkerRefs(value)) target.add(ref);
7627
+ }
7628
+ const refsToClear = [...held].filter((ref) => !kept.has(ref));
7629
+ const patch = {};
7630
+ for (const key of removed) patch[key] = void 0;
7631
+ await settings.writeAddonStore(patch);
7632
+ const secretsCleared = refsToClear.length > 0 && this._ctx?.secrets ? await this._ctx.secrets.delete(refsToClear) : 0;
7633
+ await this.resolveConfig();
7634
+ return {
7635
+ removed,
7636
+ refused,
7637
+ secretsCleared
7638
+ };
7639
+ }
7549
7640
  /** Per-device variant of {@link state}, backed by the per-device store. */
7550
7641
  deviceState(deviceId, key, schema, fallback) {
7551
7642
  return createDurableState({
@@ -7816,6 +7907,21 @@ function dropPerNodeFields(fields) {
7816
7907
  }
7817
7908
  return kept;
7818
7909
  }
7910
+ function collectFieldKeys(fields) {
7911
+ const collected = [];
7912
+ for (const field of fields) {
7913
+ if (field.type === "group") {
7914
+ collected.push(...collectFieldKeys(field.fields));
7915
+ continue;
7916
+ }
7917
+ if (field.type === "sub-tabs") {
7918
+ for (const tab of field.tabs) collected.push(...collectFieldKeys(tab.fields));
7919
+ continue;
7920
+ }
7921
+ if ("key" in field && typeof field.key === "string") collected.push(field.key);
7922
+ }
7923
+ return collected;
7924
+ }
7819
7925
  function collectPerNodeFieldKeys(fields) {
7820
7926
  const collected = [];
7821
7927
  for (const field of fields) {
@@ -16165,8 +16271,172 @@ object({
16165
16271
  quantity: ReadingQuantitySchema,
16166
16272
  unit: string().min(1)
16167
16273
  });
16168
- /** How a leaf compares a device field to a value. Derived from the field's
16169
- * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
16274
+ new Set([
16275
+ {
16276
+ id: "person",
16277
+ name: "Person"
16278
+ },
16279
+ {
16280
+ id: "vehicle",
16281
+ name: "Vehicle"
16282
+ },
16283
+ {
16284
+ id: "animal",
16285
+ name: "Animal"
16286
+ },
16287
+ {
16288
+ id: "package",
16289
+ name: "Package"
16290
+ }
16291
+ ].map((l) => l.id));
16292
+ var COCO_TO_MACRO = {
16293
+ mapping: {
16294
+ person: "person",
16295
+ bicycle: "vehicle",
16296
+ car: "vehicle",
16297
+ motorcycle: "vehicle",
16298
+ airplane: "vehicle",
16299
+ bus: "vehicle",
16300
+ train: "vehicle",
16301
+ truck: "vehicle",
16302
+ boat: "vehicle",
16303
+ bird: "animal",
16304
+ cat: "animal",
16305
+ dog: "animal",
16306
+ horse: "animal",
16307
+ sheep: "animal",
16308
+ cow: "animal",
16309
+ elephant: "animal",
16310
+ bear: "animal",
16311
+ zebra: "animal",
16312
+ giraffe: "animal",
16313
+ suitcase: "package",
16314
+ backpack: "package",
16315
+ handbag: "package"
16316
+ },
16317
+ preserveOriginal: false
16318
+ };
16319
+ /**
16320
+ * WHAT a detection-shaped leaf is about: one MACRO class (person, vehicle,
16321
+ * animal, package) plus an OPTIONAL label that narrows it.
16322
+ *
16323
+ * The taxonomy is `COCO_TO_MACRO` (and `CLASS_MAP_MACRO_TARGETS`) — never a
16324
+ * second list. The label that fits depends on the macro class:
16325
+ *
16326
+ * - `animal` → a sub-class (`dog`, `cat`, …);
16327
+ * - `person` → a known face (an identity of the `face-gallery`);
16328
+ * - `vehicle` → a plate (its text) or a sub-class (`car`, `truck`, …);
16329
+ * - `package` → a sub-class.
16330
+ *
16331
+ * `other` exists only so a class the taxonomy does not know (a custom model's
16332
+ * `forklift`) survives the conversion of a stored recipe without widening.
16333
+ *
16334
+ * A label is decided LATE (D683): face recognition and LPR land after the
16335
+ * detection starts. The track-lifecycle `update` event carries the newly
16336
+ * resolved `label` / `plateText`, which is why a leaf with a label fires when
16337
+ * the subject GETS it — not at the first detection.
16338
+ */
16339
+ var SubClassLabelSchema = object({
16340
+ kind: literal("sub-class"),
16341
+ className: string().min(1)
16342
+ });
16343
+ var IdentityLabelSchema = object({
16344
+ kind: literal("identity"),
16345
+ /** The `face-gallery` identity id (stable across a rename). */
16346
+ identityId: string().min(1),
16347
+ /** The identity's name — what a track-lifecycle event carries as `label`. */
16348
+ name: string().min(1)
16349
+ });
16350
+ var PlateLabelSchema = object({
16351
+ kind: literal("plate"),
16352
+ text: string().min(1)
16353
+ });
16354
+ function macroOf$1(className) {
16355
+ return COCO_TO_MACRO.mapping[className.toLowerCase()] ?? className.toLowerCase();
16356
+ }
16357
+ /** A sub-class label must belong to the macro it is under. */
16358
+ function subClassBelongs(macro, label) {
16359
+ return label === void 0 || macroOf$1(label.className) === macro;
16360
+ }
16361
+ var subClassOf = (macro) => SubClassLabelSchema.refine((l) => subClassBelongs(macro, l), { message: `that class is not a kind of ${macro} in the detection taxonomy` });
16362
+ var DetectionSubjectSchema = discriminatedUnion("macro", [
16363
+ object({
16364
+ macro: literal("animal"),
16365
+ label: subClassOf("animal").optional()
16366
+ }),
16367
+ object({
16368
+ macro: literal("person"),
16369
+ label: IdentityLabelSchema.optional()
16370
+ }),
16371
+ object({
16372
+ macro: literal("vehicle"),
16373
+ label: discriminatedUnion("kind", [SubClassLabelSchema.refine((l) => subClassBelongs("vehicle", l), { message: "that class is not a kind of vehicle in the detection taxonomy" }), PlateLabelSchema]).optional()
16374
+ }),
16375
+ object({
16376
+ macro: literal("package"),
16377
+ label: subClassOf("package").optional()
16378
+ }),
16379
+ object({
16380
+ macro: literal("other"),
16381
+ className: string().min(1)
16382
+ })
16383
+ ]);
16384
+ /**
16385
+ * The subject of a PARKED object. The stationary registry's event carries a
16386
+ * class and a zone, no face and no plate — an identity or plate label there
16387
+ * could never match, so the schema refuses it instead of letting it sit dead.
16388
+ */
16389
+ 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" });
16390
+ /**
16391
+ * A stored class string as a subject: a macro name stays a macro; a raw class
16392
+ * the taxonomy files under a macro becomes that macro plus the sub-class label
16393
+ * (`dog` → animal / dog); anything else is `other`, kept verbatim.
16394
+ */
16395
+ function subjectFromClassName(className) {
16396
+ const name = className.trim().toLowerCase();
16397
+ const macro = COCO_TO_MACRO.mapping[name];
16398
+ if (new Set(Object.values(COCO_TO_MACRO.mapping)).has(name)) return macroOnly(name);
16399
+ if (macro === void 0) return {
16400
+ macro: "other",
16401
+ className
16402
+ };
16403
+ const label = {
16404
+ kind: "sub-class",
16405
+ className: name
16406
+ };
16407
+ switch (macro) {
16408
+ case "animal": return {
16409
+ macro: "animal",
16410
+ label
16411
+ };
16412
+ case "vehicle": return {
16413
+ macro: "vehicle",
16414
+ label
16415
+ };
16416
+ case "package": return {
16417
+ macro: "package",
16418
+ label
16419
+ };
16420
+ default: return {
16421
+ macro: "other",
16422
+ className
16423
+ };
16424
+ }
16425
+ }
16426
+ function macroOnly(macro) {
16427
+ switch (macro) {
16428
+ case "animal": return { macro: "animal" };
16429
+ case "person": return { macro: "person" };
16430
+ case "vehicle": return { macro: "vehicle" };
16431
+ case "package": return { macro: "package" };
16432
+ default: return {
16433
+ macro: "other",
16434
+ className: macro
16435
+ };
16436
+ }
16437
+ }
16438
+ /** How a state leaf compares a device field to a value. Derived from the
16439
+ * field's `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
16170
16440
  var AutomationConditionOperatorSchema = _enum([
16171
16441
  "eq",
16172
16442
  "ne",
@@ -16177,12 +16447,76 @@ var AutomationConditionOperatorSchema = _enum([
16177
16447
  "contains",
16178
16448
  "in"
16179
16449
  ]);
16180
- var AutomationConditionLeafSchema = object({
16181
- kind: literal("condition"),
16450
+ /**
16451
+ * D8 compliance, and it is the reason a "becomes" leaf is not merely an event
16452
+ * subscription: it evaluates against the **state mirror**, which is reconciled,
16453
+ * and an event only WAKES the evaluation. A dropped event therefore DELAYS a
16454
+ * fire; it does not lose it.
16455
+ */
16456
+ var DeviceBecomesLeafSchema = object({
16457
+ kind: literal("device-becomes"),
16458
+ deviceId: number().int().nonnegative(),
16459
+ cap: string().min(1),
16460
+ fieldPath: string().min(1),
16461
+ /** Fire when the field takes this value. Omit to fire on any change. */
16462
+ becomes: union([
16463
+ string(),
16464
+ number(),
16465
+ boolean()
16466
+ ]).optional(),
16467
+ /** Only on a CHANGE of value, not on every re-report. */
16468
+ edge: boolean().optional(),
16469
+ /** The new value must hold this long before it counts. */
16470
+ forMs: number().int().min(0).max(864e5).optional(),
16471
+ /** Collapse a burst into one occurrence. */
16472
+ debounceMs: number().int().min(0).max(6e5).optional()
16473
+ });
16474
+ var DeviceEventLeafSchema = object({
16475
+ kind: literal("device-event"),
16476
+ /** An `EventCategory` value. */
16477
+ category: string().min(1),
16478
+ deviceId: number().int().nonnegative().optional()
16479
+ });
16480
+ var ScheduleLeafSchema = object({
16481
+ kind: literal("schedule"),
16482
+ cron: string().min(1).max(120)
16483
+ });
16484
+ var ManualLeafSchema = object({ kind: literal("manual") });
16485
+ var IntervalLeafSchema = object({
16486
+ kind: literal("interval"),
16487
+ minutes: number().int().min(1).max(1440)
16488
+ });
16489
+ var DetectionLeafSchema = object({
16490
+ kind: literal("detection"),
16491
+ /** One macro class each, optionally narrowed by a label; a track of any listed subject fires. */
16492
+ subjects: array(DetectionSubjectSchema).min(1),
16493
+ deviceIds: array(number().int().nonnegative()).min(1),
16494
+ /** The track must have visited at least one of these zones. */
16495
+ zones: array(string().min(1)).min(1).optional(),
16496
+ /** Best-so-far confidence the track must reach. */
16497
+ minScore: number().min(0).max(1).optional()
16498
+ });
16499
+ var StationaryLeafSchema = object({
16500
+ kind: literal("stationary"),
16501
+ deviceIds: array(number().int().nonnegative()).min(1),
16502
+ subjects: array(StationarySubjectSchema).min(1).optional(),
16503
+ zones: array(string().min(1)).min(1).optional(),
16504
+ /** `appeared`: it became parked. `departed`: the registry retired it. */
16505
+ phase: _enum(["appeared", "departed"])
16506
+ });
16507
+ /** "is": one device field compared to one value, NOW. */
16508
+ var DeviceIsLeafSchema = object({
16509
+ kind: literal("device-is"),
16182
16510
  deviceId: number().int().nonnegative(),
16183
16511
  cap: string().min(1),
16184
16512
  fieldPath: string().min(1),
16185
16513
  operator: AutomationConditionOperatorSchema,
16514
+ /**
16515
+ * The value. A number on a quantity field is stored in the field's base unit
16516
+ * (D944); a quantity value is the WRITE form, which the save boundary
16517
+ * replaces with the converted number and `unit`. A leaf that still carries
16518
+ * one is never evaluated (it fails).
16519
+ */
16186
16520
  value: union([
16187
16521
  string(),
16188
16522
  number(),
@@ -16190,24 +16524,26 @@ var AutomationConditionLeafSchema = object({
16190
16524
  array(union([string(), number()])),
16191
16525
  QuantityValueSchema
16192
16526
  ]),
16527
+ /** The unit the person picked for `value` (display only; `value` is in the base unit). */
16193
16528
  unit: string().min(1).optional()
16194
16529
  });
16195
- var AutomationConditionStationaryPresentSchema = object({
16530
+ /**
16531
+ * True when the camera currently has a PARKED object matching `subjects` in at
16532
+ * least one of `zones`. Reads the zone-analytics snapshot through the same
16533
+ * mirror as every other state leaf, so a dropped event never decides it (D49).
16534
+ */
16535
+ var StationaryPresentLeafSchema = object({
16196
16536
  kind: literal("stationary-present"),
16197
16537
  deviceId: number().int().nonnegative(),
16198
- /** Class names; a macro class matches every raw class that maps to it. */
16199
- classes: array(string().min(1)).min(1).optional(),
16200
- /** The parked object must be in at least one of these zones. */
16538
+ subjects: array(StationarySubjectSchema).min(1).optional(),
16201
16539
  zones: array(string().min(1)).min(1).optional()
16202
16540
  });
16203
16541
  /**
16204
- * The expression leaf, declared as a plain object rather than an intersection
16205
- * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
16206
- * read `kind` off each option, and an intersection hides it. The author-time
16207
- * validation is the SAME function `ExpressionSourceSchema` runs, so the two
16208
- * cannot drift — an expression that one accepts, the other accepts.
16542
+ * The escape hatch: the bounded expression engine over named device bindings.
16543
+ * Declared as a plain object so a discriminated union can read `kind`; the
16544
+ * author-time validation is the SAME function `ExpressionSourceSchema` runs.
16209
16545
  */
16210
- var AutomationConditionExpressionSchema = object({
16546
+ var ExpressionLeafSchema = object({
16211
16547
  kind: literal("expression"),
16212
16548
  expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
16213
16549
  bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
@@ -16219,55 +16555,62 @@ var AutomationConditionExpressionSchema = object({
16219
16555
  path: ["expr"]
16220
16556
  });
16221
16557
  });
16222
- var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
16558
+ var AutomationWhenSchema = lazy(() => discriminatedUnion("kind", [
16223
16559
  object({
16224
16560
  kind: literal("all"),
16225
- children: array(AutomationConditionSchema)
16561
+ children: array(AutomationWhenSchema)
16226
16562
  }),
16227
16563
  object({
16228
16564
  kind: literal("any"),
16229
- children: array(AutomationConditionSchema)
16565
+ children: array(AutomationWhenSchema)
16230
16566
  }),
16231
16567
  object({
16232
16568
  kind: literal("not"),
16233
- child: AutomationConditionSchema
16569
+ child: AutomationWhenSchema
16234
16570
  }),
16235
- AutomationConditionLeafSchema,
16236
- AutomationConditionExpressionSchema,
16237
- AutomationConditionStationaryPresentSchema
16571
+ DeviceBecomesLeafSchema,
16572
+ DeviceEventLeafSchema,
16573
+ ScheduleLeafSchema,
16574
+ ManualLeafSchema,
16575
+ IntervalLeafSchema,
16576
+ DetectionLeafSchema,
16577
+ StationaryLeafSchema,
16578
+ DeviceIsLeafSchema,
16579
+ StationaryPresentLeafSchema,
16580
+ ExpressionLeafSchema
16238
16581
  ]));
16239
16582
  /**
16240
- * What starts a run.
16241
- *
16242
- * D8 compliance, and it is the reason `device-state` is not merely an event
16243
- * subscription: the trigger evaluates against the **state mirror**, which is
16244
- * reconciled, and an event only WAKES the evaluation. A dropped event therefore
16245
- * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
16246
- * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
16247
- * not.
16248
- */
16249
- var AutomationTriggerSchema = discriminatedUnion("kind", [
16583
+ * The recipe shape before the single `when` tree: a flat list of triggers
16584
+ * (OR'd, each an instant) and an optional condition tree. Stored recipes —
16585
+ * the `AUTOMATION_RECIPE` literal of every block saved so far, and whatever an
16586
+ * older client still posts — are in this shape, so it stays READABLE here, and
16587
+ * {@link convertLegacyRecipe} turns it into the new one, losslessly and one
16588
+ * way. Nothing writes this shape any more.
16589
+ *
16590
+ * `{ triggers: [t1…tn], conditions?: C }`
16591
+ * → `when = all(any(t1…tn), C)`, or `any(t1…tn)` without `C`;
16592
+ * a single trigger is not wrapped in `any`.
16593
+ * a `condition` leaf → a `device-is` leaf (the "is" verb);
16594
+ * a `device-state` trigger → a `device-becomes` leaf (the "becomes" verb);
16595
+ * `classes: ['dog']` → the macro `animal` with the label `dog`.
16596
+ */
16597
+ var LegacyTriggerSchema = discriminatedUnion("kind", [
16250
16598
  object({
16251
16599
  kind: literal("device-state"),
16252
16600
  deviceId: number().int().nonnegative(),
16253
16601
  cap: string().min(1),
16254
16602
  fieldPath: string().min(1),
16255
- /** Fire when the field takes this value. Omit to fire on any change. */
16256
16603
  becomes: union([
16257
16604
  string(),
16258
16605
  number(),
16259
16606
  boolean()
16260
16607
  ]).optional(),
16261
- /** Only on a CHANGE of value, not on every re-report. */
16262
16608
  edge: boolean().optional(),
16263
- /** The condition must hold this long before the run starts. */
16264
16609
  forMs: number().int().min(0).max(864e5).optional(),
16265
- /** Collapse a burst into one run. */
16266
16610
  debounceMs: number().int().min(0).max(6e5).optional()
16267
16611
  }),
16268
16612
  object({
16269
16613
  kind: literal("device-event"),
16270
- /** An `EventCategory` value. */
16271
16614
  category: string().min(1),
16272
16615
  deviceId: number().int().nonnegative().optional()
16273
16616
  }),
@@ -16278,25 +16621,16 @@ var AutomationTriggerSchema = discriminatedUnion("kind", [
16278
16621
  object({ kind: literal("manual") }),
16279
16622
  object({
16280
16623
  kind: literal("detection"),
16281
- /** Class names. A macro class (`animal`, `vehicle`, `person`, `package`)
16282
- * matches every raw class that maps to it; any other name matches
16283
- * exactly. */
16284
16624
  classes: array(string().min(1)).min(1),
16285
16625
  deviceIds: array(number().int().nonnegative()).min(1),
16286
- /** The track must have visited at least one of these zones. */
16287
16626
  zones: array(string().min(1)).min(1).optional(),
16288
- /** Best-so-far confidence the track must reach. */
16289
16627
  minScore: number().min(0).max(1).optional()
16290
16628
  }),
16291
16629
  object({
16292
16630
  kind: literal("stationary"),
16293
16631
  deviceIds: array(number().int().nonnegative()).min(1),
16294
- /** Class names; a macro class matches like the detection trigger. */
16295
16632
  classes: array(string().min(1)).min(1).optional(),
16296
- /** The parked object must be in at least one of these zones. */
16297
16633
  zones: array(string().min(1)).min(1).optional(),
16298
- /** `appeared`: it became parked. `departed`: the registry retired it.
16299
- * The vocabulary of `PipelineAnalyticsStationaryChanged`. */
16300
16634
  phase: _enum(["appeared", "departed"])
16301
16635
  }),
16302
16636
  object({
@@ -16304,6 +16638,143 @@ var AutomationTriggerSchema = discriminatedUnion("kind", [
16304
16638
  minutes: number().int().min(1).max(1440)
16305
16639
  })
16306
16640
  ]);
16641
+ var LegacyConditionLeafSchema = object({
16642
+ kind: literal("condition"),
16643
+ deviceId: number().int().nonnegative(),
16644
+ cap: string().min(1),
16645
+ fieldPath: string().min(1),
16646
+ operator: AutomationConditionOperatorSchema,
16647
+ value: union([
16648
+ string(),
16649
+ number(),
16650
+ boolean(),
16651
+ array(union([string(), number()])),
16652
+ QuantityValueSchema
16653
+ ]),
16654
+ unit: string().min(1).optional()
16655
+ });
16656
+ var LegacyStationaryPresentSchema = object({
16657
+ kind: literal("stationary-present"),
16658
+ deviceId: number().int().nonnegative(),
16659
+ classes: array(string().min(1)).min(1).optional(),
16660
+ zones: array(string().min(1)).min(1).optional()
16661
+ });
16662
+ var LegacyExpressionSchema = object({
16663
+ kind: literal("expression"),
16664
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
16665
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
16666
+ }).superRefine((src, ctx) => {
16667
+ const err = validateExpressionSource(src);
16668
+ if (err !== null) ctx.addIssue({
16669
+ code: "custom",
16670
+ message: err,
16671
+ path: ["expr"]
16672
+ });
16673
+ });
16674
+ var LegacyConditionSchema = lazy(() => discriminatedUnion("kind", [
16675
+ object({
16676
+ kind: literal("all"),
16677
+ children: array(LegacyConditionSchema)
16678
+ }),
16679
+ object({
16680
+ kind: literal("any"),
16681
+ children: array(LegacyConditionSchema)
16682
+ }),
16683
+ object({
16684
+ kind: literal("not"),
16685
+ child: LegacyConditionSchema
16686
+ }),
16687
+ LegacyConditionLeafSchema,
16688
+ LegacyExpressionSchema,
16689
+ LegacyStationaryPresentSchema
16690
+ ]));
16691
+ function convertTrigger(trigger) {
16692
+ switch (trigger.kind) {
16693
+ case "device-state": return {
16694
+ ...trigger,
16695
+ kind: "device-becomes"
16696
+ };
16697
+ case "detection": {
16698
+ const { classes, ...rest } = trigger;
16699
+ return {
16700
+ ...rest,
16701
+ subjects: classes.map(subjectFromClassName)
16702
+ };
16703
+ }
16704
+ case "stationary": {
16705
+ const { classes, ...rest } = trigger;
16706
+ return {
16707
+ ...rest,
16708
+ ...classes === void 0 ? {} : { subjects: classes.map(subjectFromClassName) }
16709
+ };
16710
+ }
16711
+ default: return trigger;
16712
+ }
16713
+ }
16714
+ /** An old condition tree as a `when` subtree: `condition` → "is", classes → subjects. */
16715
+ function convertLegacyCondition(condition) {
16716
+ switch (condition.kind) {
16717
+ case "condition": return {
16718
+ ...condition,
16719
+ kind: "device-is"
16720
+ };
16721
+ case "stationary-present": {
16722
+ const { classes, ...rest } = condition;
16723
+ return {
16724
+ ...rest,
16725
+ ...classes === void 0 ? {} : { subjects: classes.map(subjectFromClassName) }
16726
+ };
16727
+ }
16728
+ case "expression": return condition;
16729
+ case "not": return {
16730
+ kind: "not",
16731
+ child: convertLegacyCondition(condition.child)
16732
+ };
16733
+ case "all":
16734
+ case "any": return {
16735
+ kind: condition.kind,
16736
+ children: condition.children.map(convertLegacyCondition)
16737
+ };
16738
+ }
16739
+ }
16740
+ /** `all(any(t1…tn), C)`, or `any(t1…tn)`; a single trigger is not wrapped in `any`. */
16741
+ function convertLegacyWhen(half) {
16742
+ const leaves = half.triggers.map(convertTrigger);
16743
+ const first = leaves[0];
16744
+ const triggers = leaves.length === 1 && first !== void 0 ? first : {
16745
+ kind: "any",
16746
+ children: leaves
16747
+ };
16748
+ if (half.conditions === void 0) return triggers;
16749
+ return {
16750
+ kind: "all",
16751
+ children: [triggers, convertLegacyCondition(half.conditions)]
16752
+ };
16753
+ }
16754
+ /**
16755
+ * The automation RECIPE — what a visually-authored automation is, before it
16756
+ * becomes code.
16757
+ *
16758
+ * This is the data half of `docs/design/2026-08-08-automation-scripts-custom-devices.md`
16759
+ * Phase 6. A recipe is not an engine and never evaluates anything: it is a
16760
+ * declarative description that {@link generateAutomationBlock} compiles to the
16761
+ * TypeScript of an ordinary core block. There is therefore no second rule
16762
+ * engine, no second store and no second runtime — the block runner is the
16763
+ * runtime, `core_blocks` is the store (§2.1).
16764
+ *
16765
+ * Two parts:
16766
+ *
16767
+ * - **`when`** — ONE tree (`./when.ts`) of all/any/not over event leaves
16768
+ * (instants) and state leaves (durations). It replaces the flat trigger list
16769
+ * plus the separate condition tree; the firing rule is in that file and in
16770
+ * ADR "one tree".
16771
+ * - **Actions** — `NcRuleActionSchema`'s two steps, plus the `{kind:'code'}`
16772
+ * fragment of §3.2.2. See {@link AutomationActionSchema} for the ONE place
16773
+ * this had to diverge from "verbatim", and why.
16774
+ *
16775
+ * A recipe stored in the old shape (`triggers` + `conditions`) still LOADS:
16776
+ * {@link AutomationRecipeSchema} (the parse-anywhere schema) converts it on parse. Nothing writes it.
16777
+ */
16307
16778
  /**
16308
16779
  * One action step.
16309
16780
  *
@@ -16406,69 +16877,49 @@ var AutomationRunModeSchema = discriminatedUnion("mode", [
16406
16877
  minutes: number().int().min(1).max(1440)
16407
16878
  })
16408
16879
  ]);
16409
- var AutomationRecipeSchema = object({
16410
- triggers: array(AutomationTriggerSchema),
16411
- conditions: AutomationConditionSchema.optional(),
16880
+ var CurrentAutomationRecipeSchema = object({
16881
+ when: AutomationWhenSchema,
16412
16882
  actions: array(AutomationActionSchema),
16413
16883
  runMode: AutomationRunModeSchema.optional(),
16414
16884
  /** How many run traces the automation keeps (default {@link DEFAULT_TRACE_KEEP}). */
16415
16885
  keepTraces: number().int().min(1).max(100).optional()
16416
16886
  });
16417
- new Set([
16418
- {
16419
- id: "person",
16420
- name: "Person"
16421
- },
16422
- {
16423
- id: "vehicle",
16424
- name: "Vehicle"
16425
- },
16426
- {
16427
- id: "animal",
16428
- name: "Animal"
16429
- },
16430
- {
16431
- id: "package",
16432
- name: "Package"
16433
- }
16434
- ].map((l) => l.id));
16435
- var COCO_TO_MACRO = {
16436
- mapping: {
16437
- person: "person",
16438
- bicycle: "vehicle",
16439
- car: "vehicle",
16440
- motorcycle: "vehicle",
16441
- airplane: "vehicle",
16442
- bus: "vehicle",
16443
- train: "vehicle",
16444
- truck: "vehicle",
16445
- boat: "vehicle",
16446
- bird: "animal",
16447
- cat: "animal",
16448
- dog: "animal",
16449
- horse: "animal",
16450
- sheep: "animal",
16451
- cow: "animal",
16452
- elephant: "animal",
16453
- bear: "animal",
16454
- zebra: "animal",
16455
- giraffe: "animal",
16456
- suitcase: "package",
16457
- backpack: "package",
16458
- handbag: "package"
16459
- },
16460
- preserveOriginal: false
16461
- };
16887
+ /** A recipe as it was stored before the single tree. Read-only: nothing writes this. */
16888
+ var LegacyAutomationRecipeSchema = object({
16889
+ triggers: array(LegacyTriggerSchema),
16890
+ conditions: LegacyConditionSchema.optional(),
16891
+ actions: array(AutomationActionSchema),
16892
+ runMode: AutomationRunModeSchema.optional(),
16893
+ keepTraces: number().int().min(1).max(100).optional()
16894
+ });
16895
+ /** The old shape as the new one, lossless. */
16896
+ function convertLegacyRecipe(legacy) {
16897
+ const { triggers, conditions, ...rest } = legacy;
16898
+ return {
16899
+ when: convertLegacyWhen({
16900
+ triggers,
16901
+ conditions
16902
+ }),
16903
+ ...rest
16904
+ };
16905
+ }
16906
+ /**
16907
+ * Where recipes are PARSED (the core-blocks cap, the MCP tool, the literal a
16908
+ * saved block carries): the new shape as it is, or the old shape converted to
16909
+ * it. The output type is always the new one.
16910
+ */
16911
+ var AutomationRecipeSchema = union([CurrentAutomationRecipeSchema, LegacyAutomationRecipeSchema.transform(convertLegacyRecipe)]);
16462
16912
  new Set(Object.values(COCO_TO_MACRO.mapping));
16463
16913
  /**
16464
16914
  * Run traces — what an automation (or, later, an Action run) DID, node by node,
16465
16915
  * so the editor can paint every block green or red with its reason.
16466
16916
  *
16467
- * A trace is data, not a log: it covers the WHOLE tree — the trigger stage,
16468
- * every condition node (containers and leaves, including the ones a
16469
- * short-circuit never reached), the run-mode verdict and every action step —
16470
- * each addressed by a stable path (`conditions.0.1`, `actions.2`, or `steps.N`
16471
- * for an Action run). Skipped
16917
+ * A trace is data, not a log: it covers the WHOLE tree — the event leaf that
16918
+ * fired, every node of the `when` tree (containers and leaves, including the
16919
+ * ones a short-circuit never reached), the run-mode verdict and every action
16920
+ * step — each addressed by a stable path (`when.0.1`, `actions.2`, or `steps.N`
16921
+ * for an Action run). Traces written before the single tree carry
16922
+ * `conditions.*` paths and still parse. Skipped
16472
16923
  * evaluations are traced too, and identical consecutive skips collapse into a
16473
16924
  * repeat count so a trigger that fails every 5 minutes cannot flush real runs.
16474
16925
  *
@@ -16500,6 +16951,8 @@ var TraceNodeOutcomeSchema = _enum([
16500
16951
  ]);
16501
16952
  var AutomationTriggerTraceSchema = object({
16502
16953
  kind: string().min(1),
16954
+ /** The event leaf that fired (`when`, `when.0.1`); absent on traces written before the single tree. */
16955
+ path: string().min(1).optional(),
16503
16956
  outcome: _enum(["pass", "fail"]),
16504
16957
  /** Why an event was rejected ("class person not in [dog]") or what fired. */
16505
16958
  reason: string(),
@@ -16507,15 +16960,23 @@ var AutomationTriggerTraceSchema = object({
16507
16960
  vars: record(string(), TraceValueSchema)
16508
16961
  });
16509
16962
  var AutomationConditionTraceNodeSchema = object({
16510
- /** `conditions` for the root, `conditions.<i>` for its i-th child, … */
16963
+ /** `when` for the root, `when.<i>` for its i-th child, … (`conditions…` on an old trace). */
16511
16964
  path: string().min(1),
16512
16965
  kind: _enum([
16513
16966
  "all",
16514
16967
  "any",
16515
16968
  "not",
16516
16969
  "condition",
16970
+ "device-is",
16517
16971
  "expression",
16518
- "stationary-present"
16972
+ "stationary-present",
16973
+ "device-becomes",
16974
+ "device-event",
16975
+ "schedule",
16976
+ "interval",
16977
+ "manual",
16978
+ "detection",
16979
+ "stationary"
16519
16980
  ]),
16520
16981
  outcome: TraceNodeOutcomeSchema,
16521
16982
  reason: string(),
@@ -16549,6 +17010,12 @@ var AutomationStepTraceSchema = object({
16549
17010
  deviceId: number().int().optional(),
16550
17011
  startedAt: number().optional(),
16551
17012
  endedAt: number().optional(),
17013
+ /**
17014
+ * The parameters the step SENT, after `{{vars.x}}` substitution — what the
17015
+ * device was actually asked. Bounded like trigger `vars`; a secret parameter
17016
+ * is shown as {@link REDACTED_ARG}. Absent on traces written before it existed.
17017
+ */
17018
+ args: record(string(), TraceValueSchema).optional(),
16552
17019
  outcome: _enum([
16553
17020
  "ok",
16554
17021
  "error",
@@ -17298,6 +17765,23 @@ method(object({
17298
17765
  addonId: string(),
17299
17766
  nodeId: string().optional()
17300
17767
  }), SettingsSchemaWithValuesSchema.nullable()), method(object({
17768
+ addonId: string(),
17769
+ nodeId: string().optional()
17770
+ }), object({ keys: array(string()) }).nullable(), { auth: "admin" }), method(object({
17771
+ addonId: string(),
17772
+ nodeId: string().optional(),
17773
+ keys: array(string())
17774
+ }), object({
17775
+ removed: array(string()),
17776
+ refused: array(object({
17777
+ key: string(),
17778
+ reason: string()
17779
+ })),
17780
+ secretsCleared: number()
17781
+ }), {
17782
+ kind: "mutation",
17783
+ auth: "admin"
17784
+ }), method(object({
17301
17785
  addonId: string(),
17302
17786
  deviceId: number(),
17303
17787
  nodeId: string().optional()
@@ -21327,6 +21811,8 @@ var StorageKindSchema = object({
21327
21811
  "orphaned",
21328
21812
  "dropped"
21329
21813
  ]),
21814
+ /** An owner declared this kind since the engine started. */
21815
+ declaredThisBoot: boolean(),
21330
21816
  /** `null` when the caller asked for no counts (`counts: false`). */
21331
21817
  rows: number().int().nonnegative().nullable(),
21332
21818
  /** Rows x the average size of a sample; null for an empty kind. */
@@ -21338,6 +21824,51 @@ var StorageKindSchema = object({
21338
21824
  /** The whole declaration, secret paths and entry descriptor included (the entries service builds its handle from it). */
21339
21825
  declaration: RecordKindDeclarationSchema
21340
21826
  });
21827
+ _enum([
21828
+ "orphaned",
21829
+ "stale",
21830
+ "unknown"
21831
+ ]);
21832
+ /** A record kind nobody declared since its owner started, or whose owner is gone. */
21833
+ var StaleKindFindingSchema = object({
21834
+ owner: string(),
21835
+ kind: string(),
21836
+ /** `unknown`: the owner could not be judged (not running, or its state unreadable) - never dropped. */
21837
+ verdict: _enum(["orphaned", "unknown"]),
21838
+ reason: string(),
21839
+ rows: number().int().nonnegative().nullable(),
21840
+ /** local-auth's tables are never dropped by this audit. */
21841
+ protected: boolean()
21842
+ });
21843
+ /** A stored global-settings key the running addon no longer declares. */
21844
+ var StaleSettingsKeyFindingSchema = object({
21845
+ addonId: string(),
21846
+ /** The stored key as written, a per-node `<key>@<nodeId>` suffix included. */
21847
+ key: string(),
21848
+ /**
21849
+ * Keychain refs whose ONLY marker sits under this key: in use by stale data,
21850
+ * not orphans yet. Removing the key releases them.
21851
+ */
21852
+ secretRefs: array(string())
21853
+ });
21854
+ /** An addon the audit could not judge, with the reason. Never reported as stale. */
21855
+ var StaleUnknownSchema = object({
21856
+ subject: string(),
21857
+ reason: string()
21858
+ });
21859
+ var StaleDataReportSchema = object({
21860
+ /** Epoch ms of the last finished scan; `null` before the first. */
21861
+ scannedAt: number().nullable(),
21862
+ scanning: boolean(),
21863
+ trigger: _enum([
21864
+ "boot",
21865
+ "interval",
21866
+ "manual"
21867
+ ]).nullable(),
21868
+ kinds: array(StaleKindFindingSchema),
21869
+ settingsKeys: array(StaleSettingsKeyFindingSchema),
21870
+ unknown: array(StaleUnknownSchema)
21871
+ });
21341
21872
  method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: array(StorageTableSchema) }), { auth: "admin" }), method(object({
21342
21873
  owner: string().min(1).optional(),
21343
21874
  /** `false` skips the per-kind row count (a catalog read only). Default true. */
@@ -21345,7 +21876,30 @@ method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: arr
21345
21876
  }), object({ kinds: array(StorageKindSchema) }), { auth: "admin" }), method(RecordQueryInputSchema.extend({
21346
21877
  owner: string().min(1),
21347
21878
  kind: RecordKindNameSchema
21348
- }), RecordQueryResultSchema, { auth: "admin" });
21879
+ }), RecordQueryResultSchema, { auth: "admin" }), method(object({}), StaleDataReportSchema, { auth: "admin" }), method(object({}), StaleDataReportSchema, {
21880
+ kind: "mutation",
21881
+ auth: "admin"
21882
+ }), method(object({
21883
+ owner: string().min(1),
21884
+ kind: RecordKindNameSchema
21885
+ }), object({
21886
+ deleted: number().int().nonnegative(),
21887
+ secretsReleased: number().int().nonnegative()
21888
+ }), {
21889
+ kind: "mutation",
21890
+ auth: "admin",
21891
+ caller: "required"
21892
+ }), method(object({
21893
+ addonId: string().min(1),
21894
+ key: string().min(1)
21895
+ }), object({
21896
+ removed: boolean(),
21897
+ secretsCleared: number().int().nonnegative()
21898
+ }), {
21899
+ kind: "mutation",
21900
+ auth: "admin",
21901
+ caller: "required"
21902
+ });
21349
21903
  /**
21350
21904
  * record-entries - the ONE admin surface for every list of user-created entries
21351
21905
  * (D881): notification targets, brokers, LLM profiles, anything an addon
@@ -21484,6 +22038,12 @@ method(object({
21484
22038
  }), method(object({ deviceId: number().int() }), object({ refs: array(string()).readonly() }), {
21485
22039
  kind: "mutation",
21486
22040
  auth: "admin"
22041
+ }), method(object({
22042
+ refs: RefListSchema,
22043
+ onBehalfOf: string().min(1)
22044
+ }), object({ released: number().int() }), {
22045
+ kind: "mutation",
22046
+ auth: "admin"
21487
22047
  }), method(object({}), array(KeychainSecretMetaSchema).readonly(), { auth: "admin" }), method(object({}), KeychainStateSchema, { auth: "admin" });
21488
22048
  /**
21489
22049
  * device-history — the ONE mechanism for numeric history (D850).
@@ -22386,7 +22946,9 @@ var DiscoveredChildDeviceSchema = lazy(() => object({
22386
22946
  /** Upstream-system identity + rendering hints for the candidate. */
22387
22947
  sourceInfo: SourceInfoSchema.optional(),
22388
22948
  /** Nested entity-children for accordion preview — integrations populate, Hubs omit. */
22389
- children: array(DiscoveredChildDeviceSchema).readonly().optional()
22949
+ children: array(DiscoveredChildDeviceSchema).readonly().optional(),
22950
+ /** `childNativeId` of the gateway candidate in the same list this device sits behind. */
22951
+ parentStableId: string().optional()
22390
22952
  }));
22391
22953
  /**
22392
22954
  * Status block — populated by the integration after each
@@ -42769,6 +43331,79 @@ object({
42769
43331
  metadata: record(string(), unknown()).optional()
42770
43332
  });
42771
43333
  /**
43334
+ * HOW a provider can be found on the network. The provider only DECLARES the
43335
+ * mode; the `local-network` scan engine runs it once for every provider that
43336
+ * asked (one multicast browse, one host sweep) and hands the raw hits back to
43337
+ * `classifyDiscoveryHits`. A provider never opens a discovery socket itself,
43338
+ * except through a `custom` mode.
43339
+ */
43340
+ var DiscoveryModeSchema = discriminatedUnion("kind", [
43341
+ object({
43342
+ kind: literal("mdns"),
43343
+ /** DNS-SD service type, e.g. `_esphomelib._tcp`. */
43344
+ serviceType: string().min(1),
43345
+ /** Regular expression an instance name must match (case-insensitive). */
43346
+ instancePattern: string().optional()
43347
+ }),
43348
+ object({
43349
+ kind: literal("ws-discovery"),
43350
+ /** Probe types (e.g. `dn:NetworkVideoTransmitter`); omitted = any device. */
43351
+ types: array(string()).optional()
43352
+ }),
43353
+ object({
43354
+ kind: literal("ssdp"),
43355
+ searchTarget: string().min(1)
43356
+ }),
43357
+ object({
43358
+ kind: literal("udp-broadcast"),
43359
+ port: number().int().min(1).max(65535),
43360
+ payloadHex: string().regex(/^([0-9a-fA-F]{2})*$/),
43361
+ /** Port replies arrive on, when the device answers somewhere other than the source port. */
43362
+ listenPort: number().int().min(1).max(65535).optional()
43363
+ }),
43364
+ object({
43365
+ kind: literal("http-probe"),
43366
+ port: number().int().min(1).max(65535),
43367
+ path: string().startsWith("/"),
43368
+ method: _enum(["GET", "POST"]).default("GET"),
43369
+ bodyText: string().optional(),
43370
+ contentType: string().optional(),
43371
+ tls: boolean().default(false),
43372
+ /** Status codes that count as a hit. Omitted = any HTTP answer. */
43373
+ acceptStatus: array(number().int()).optional()
43374
+ }),
43375
+ object({
43376
+ kind: literal("custom"),
43377
+ /** Why the engine cannot do it (an allowlisted exception, e.g. a vendor UDP protocol). */
43378
+ reason: string().min(1)
43379
+ })
43380
+ ]);
43381
+ /** What the engine saw on the wire for ONE declared mode; the provider classifies it. */
43382
+ var DiscoveryHitSchema = object({
43383
+ /** Index into the provider's own `getDiscoveryModes()` array. */
43384
+ modeIndex: number().int().min(0),
43385
+ host: string(),
43386
+ port: number().int(),
43387
+ http: object({
43388
+ status: number().int(),
43389
+ headers: record(string(), string()),
43390
+ /** At most 16 KiB. */
43391
+ bodyText: string()
43392
+ }).optional(),
43393
+ mdns: object({
43394
+ instance: string(),
43395
+ host: string(),
43396
+ port: number().int(),
43397
+ txt: record(string(), string())
43398
+ }).optional(),
43399
+ udp: object({ payloadHex: string() }).optional(),
43400
+ wsd: object({
43401
+ xaddrs: array(string()),
43402
+ types: array(string()),
43403
+ scopes: array(string())
43404
+ }).optional()
43405
+ });
43406
+ /**
42772
43407
  * Candidate handed back from discovery and accepted by
42773
43408
  * `adoptDiscoveredDevice`. Shape mirrors the in-process
42774
43409
  * `DiscoveredDevice` interface used by `DeviceDiscovery`.
@@ -42803,7 +43438,17 @@ var DiscoveryCandidateSchema = object({
42803
43438
  /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
42804
43439
  onboardedDeviceId: number().optional(),
42805
43440
  /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
42806
- onboardedName: string().optional()
43441
+ onboardedName: string().optional(),
43442
+ /**
43443
+ * This candidate is an INTEGRATION to set up (a gateway, a hub, a controller),
43444
+ * not a device to adopt. Replaces the `prefilledConfig.setupIntegration` marker,
43445
+ * which is still read for one release.
43446
+ */
43447
+ setupIntegration: boolean().optional(),
43448
+ /** `stableId` of the gateway candidate this device was found BEHIND. */
43449
+ parentStableId: string().optional(),
43450
+ /** `false` = shown for context but not pickable. Default true. */
43451
+ selectable: boolean().optional()
42807
43452
  });
42808
43453
  /**
42809
43454
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -42900,6 +43545,24 @@ var deviceProviderCapability = {
42900
43545
  auth: "admin"
42901
43546
  }),
42902
43547
  /**
43548
+ * How this provider is found on the network. `[]` = the provider has no
43549
+ * declarative mode and keeps answering `discoverDevices` itself.
43550
+ */
43551
+ getDiscoveryModes: method(object({}), array(DiscoveryModeSchema)),
43552
+ /**
43553
+ * Turn the engine's raw hits into candidates. The provider may make
43554
+ * follow-up UNICAST requests to a hit host (an authenticated call, an
43555
+ * identity check) and never sweeps a subnet. `params` is the per-scan form
43556
+ * from `getDiscoveryParamsSchema`.
43557
+ */
43558
+ classifyDiscoveryHits: method(object({
43559
+ hits: array(DiscoveryHitSchema),
43560
+ params: record(string(), unknown()).optional()
43561
+ }), array(DiscoveryCandidateSchema), {
43562
+ kind: "mutation",
43563
+ auth: "admin"
43564
+ }),
43565
+ /**
42903
43566
  * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
42904
43567
  * provider accepts (e.g. Gree's broadcast address for a different subnet).
42905
43568
  * `null` when the provider takes no extra scan params — the generic
@@ -43664,17 +44327,23 @@ method(object({
43664
44327
  }), method(object({}), object({ providers: array(object({
43665
44328
  addonId: string(),
43666
44329
  label: string()
43667
- })).readonly() }), { auth: "admin" }), method(object({}), object({ groups: array(object({
43668
- addonId: string(),
43669
- label: string(),
43670
- candidates: array(DiscoveryCandidateSchema).readonly(),
43671
- error: string().nullable()
43672
- })).readonly() }), {
43673
- kind: "mutation",
43674
- auth: "admin"
43675
- }), method(object({
44330
+ })).readonly() }), { auth: "admin" }), method(
44331
+ /** `subnets`: extra CIDRs for THIS scan, on top of the hub's own and the saved list. */
44332
+ object({ subnets: array(string()).optional() }),
44333
+ object({ groups: array(object({
44334
+ addonId: string(),
44335
+ label: string(),
44336
+ candidates: array(DiscoveryCandidateSchema).readonly(),
44337
+ error: string().nullable()
44338
+ })).readonly() }),
44339
+ {
44340
+ kind: "mutation",
44341
+ auth: "admin"
44342
+ }
44343
+ ), method(object({
43676
44344
  addonId: string(),
43677
- params: record(string(), unknown()).optional()
44345
+ params: record(string(), unknown()).optional(),
44346
+ subnets: array(string()).optional()
43678
44347
  }), object({ candidates: array(DiscoveryCandidateSchema).readonly() }), {
43679
44348
  kind: "mutation",
43680
44349
  auth: "admin"
@@ -63016,7 +63685,9 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
63016
63685
  id: string(),
63017
63686
  settings: record(string(), unknown()),
63018
63687
  /** Keys the form's schema declares secret (by type) — keychain-stored (D787). */
63019
- secretKeys: array(string()).optional()
63688
+ secretKeys: array(string()).optional(),
63689
+ /** The integration's own addon applies the change in-process and asks the hub not to respawn it. */
63690
+ skipRestart: boolean().optional()
63020
63691
  }), object({ success: boolean() }), {
63021
63692
  kind: "mutation",
63022
63693
  auth: "admin"
@@ -63644,6 +64315,39 @@ var TlsStatusSchema = object({
63644
64315
  hasCa: boolean()
63645
64316
  }).nullable()
63646
64317
  });
64318
+ /** One provider's ask of the scan engine: the modes it declared. */
64319
+ var ScanRequestSchema = object({
64320
+ providerId: string().min(1),
64321
+ modes: array(DiscoveryModeSchema)
64322
+ });
64323
+ var ScanErrorSchema = object({
64324
+ /** Absent for a failure that is the scan's own (a bad subnet, a socket that would not open). */
64325
+ providerId: string().optional(),
64326
+ /** The mode family that failed (`mdns`, `http-probe`, …). */
64327
+ mode: string().optional(),
64328
+ message: string()
64329
+ });
64330
+ /** What one scan did, per mode family; the engine logs the same numbers once. */
64331
+ var ScanStatsSchema = object({
64332
+ hostsProbed: number().int(),
64333
+ hits: number().int(),
64334
+ timeouts: number().int(),
64335
+ byMode: record(string(), object({
64336
+ probed: number().int(),
64337
+ hits: number().int(),
64338
+ timeouts: number().int()
64339
+ }))
64340
+ });
64341
+ var ScanNetworkResultSchema = object({
64342
+ hits: array(object({
64343
+ providerId: string(),
64344
+ hit: DiscoveryHitSchema
64345
+ })),
64346
+ /** CIDRs actually covered: the hub's own /24s, the saved list and the per-call list. */
64347
+ subnetsScanned: array(string()),
64348
+ errors: array(ScanErrorSchema),
64349
+ stats: ScanStatsSchema
64350
+ });
63647
64351
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
63648
64352
  /**
63649
64353
  * LEGACY HINT — do not send from new code. Kept optional so clients
@@ -63698,6 +64402,16 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
63698
64402
  }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
63699
64403
  kind: "mutation",
63700
64404
  auth: "admin"
64405
+ }), method(object({
64406
+ requests: array(ScanRequestSchema),
64407
+ subnets: array(string()).optional()
64408
+ }), ScanNetworkResultSchema, {
64409
+ kind: "mutation",
64410
+ auth: "admin",
64411
+ timeoutMs: 9e4
64412
+ }), method(_void(), object({ subnets: array(string()) }), { auth: "admin" }), method(object({ subnets: array(string()) }), object({ subnets: array(string()) }), {
64413
+ kind: "mutation",
64414
+ auth: "admin"
63701
64415
  }), method(_void(), TlsStatusSchema, {
63702
64416
  kind: "mutation",
63703
64417
  auth: "admin"
@@ -73280,6 +73994,17 @@ var BaseDeviceProvider = class extends BaseAddon {
73280
73994
  async discoverDevices(_input) {
73281
73995
  return [];
73282
73996
  }
73997
+ /**
73998
+ * How the scan engine finds this provider. Default `[]`: the provider is
73999
+ * not migrated and keeps answering `discoverDevices` itself.
74000
+ */
74001
+ async getDiscoveryModes() {
74002
+ return [];
74003
+ }
74004
+ /** Turn the scan engine's raw hits into candidates. Default: none. */
74005
+ async classifyDiscoveryHits(_input) {
74006
+ return [];
74007
+ }
73283
74008
  /** Extra per-scan input form (e.g. a broadcast address for another subnet).
73284
74009
  * Null = no extra params. Override in providers that support scoped scans. */
73285
74010
  async getDiscoveryParamsSchema() {
@@ -74148,6 +74873,13 @@ Object.freeze({
74148
74873
  access: "create",
74149
74874
  role: "admin"
74150
74875
  },
74876
+ "addonSettings.getDeclaredSettingsKeys": {
74877
+ capName: "addon-settings",
74878
+ capScope: "system",
74879
+ addonId: null,
74880
+ access: "view",
74881
+ role: "admin"
74882
+ },
74151
74883
  "addonSettings.getDeviceSettings": {
74152
74884
  capName: "addon-settings",
74153
74885
  capScope: "system",
@@ -74169,6 +74901,13 @@ Object.freeze({
74169
74901
  access: "view",
74170
74902
  role: "admin"
74171
74903
  },
74904
+ "addonSettings.unsetStoredSettingsKeys": {
74905
+ capName: "addon-settings",
74906
+ capScope: "system",
74907
+ addonId: null,
74908
+ access: "create",
74909
+ role: "admin"
74910
+ },
74172
74911
  "addonSettings.updateDeviceSettings": {
74173
74912
  capName: "addon-settings",
74174
74913
  capScope: "system",
@@ -76584,6 +77323,13 @@ Object.freeze({
76584
77323
  access: "create",
76585
77324
  role: "admin"
76586
77325
  },
77326
+ "deviceProvider.classifyDiscoveryHits": {
77327
+ capName: "device-provider",
77328
+ capScope: "system",
77329
+ addonId: null,
77330
+ access: "create",
77331
+ role: "admin"
77332
+ },
76587
77333
  "deviceProvider.createDevice": {
76588
77334
  capName: "device-provider",
76589
77335
  capScope: "system",
@@ -76612,6 +77358,13 @@ Object.freeze({
76612
77358
  access: "view",
76613
77359
  role: "admin"
76614
77360
  },
77361
+ "deviceProvider.getDiscoveryModes": {
77362
+ capName: "device-provider",
77363
+ capScope: "system",
77364
+ addonId: null,
77365
+ access: "view",
77366
+ role: "admin"
77367
+ },
76615
77368
  "deviceProvider.getDiscoveryParamsSchema": {
76616
77369
  capName: "device-provider",
76617
77370
  capScope: "system",
@@ -77389,6 +78142,13 @@ Object.freeze({
77389
78142
  access: "create",
77390
78143
  role: "admin"
77391
78144
  },
78145
+ "keychain.releaseRecordRefs": {
78146
+ capName: "keychain",
78147
+ capScope: "system",
78148
+ addonId: null,
78149
+ access: "create",
78150
+ role: "admin"
78151
+ },
77392
78152
  "keychain.resolve": {
77393
78153
  capName: "keychain",
77394
78154
  capScope: "system",
@@ -77809,6 +78569,13 @@ Object.freeze({
77809
78569
  access: "view",
77810
78570
  role: "admin"
77811
78571
  },
78572
+ "localNetwork.getScanSubnets": {
78573
+ capName: "local-network",
78574
+ capScope: "system",
78575
+ addonId: null,
78576
+ access: "view",
78577
+ role: "admin"
78578
+ },
77812
78579
  "localNetwork.getTlsStatus": {
77813
78580
  capName: "local-network",
77814
78581
  capScope: "system",
@@ -77858,6 +78625,13 @@ Object.freeze({
77858
78625
  access: "create",
77859
78626
  role: "admin"
77860
78627
  },
78628
+ "localNetwork.scanNetwork": {
78629
+ capName: "local-network",
78630
+ capScope: "system",
78631
+ addonId: null,
78632
+ access: "create",
78633
+ role: "admin"
78634
+ },
77861
78635
  "localNetwork.setAddressRole": {
77862
78636
  capName: "local-network",
77863
78637
  capScope: "system",
@@ -77879,6 +78653,13 @@ Object.freeze({
77879
78653
  access: "create",
77880
78654
  role: "admin"
77881
78655
  },
78656
+ "localNetwork.setScanSubnets": {
78657
+ capName: "local-network",
78658
+ capScope: "system",
78659
+ addonId: null,
78660
+ access: "create",
78661
+ role: "admin"
78662
+ },
77882
78663
  "localNetwork.setViewerEndpoints": {
77883
78664
  capName: "local-network",
77884
78665
  capScope: "system",
@@ -82100,6 +82881,13 @@ Object.freeze({
82100
82881
  access: "create",
82101
82882
  role: "admin"
82102
82883
  },
82884
+ "storageAdmin.dropOrphanedKind": {
82885
+ capName: "storage-admin",
82886
+ capScope: "system",
82887
+ addonId: null,
82888
+ access: "delete",
82889
+ role: "admin"
82890
+ },
82103
82891
  "storageAdmin.listKinds": {
82104
82892
  capName: "storage-admin",
82105
82893
  capScope: "system",
@@ -82121,6 +82909,27 @@ Object.freeze({
82121
82909
  access: "view",
82122
82910
  role: "admin"
82123
82911
  },
82912
+ "storageAdmin.scanStaleData": {
82913
+ capName: "storage-admin",
82914
+ capScope: "system",
82915
+ addonId: null,
82916
+ access: "create",
82917
+ role: "admin"
82918
+ },
82919
+ "storageAdmin.staleDataReport": {
82920
+ capName: "storage-admin",
82921
+ capScope: "system",
82922
+ addonId: null,
82923
+ access: "view",
82924
+ role: "admin"
82925
+ },
82926
+ "storageAdmin.unsetStaleSettingsKey": {
82927
+ capName: "storage-admin",
82928
+ capScope: "system",
82929
+ addonId: null,
82930
+ access: "create",
82931
+ role: "admin"
82932
+ },
82124
82933
  "storageEvictable.evict": {
82125
82934
  capName: "storage-evictable",
82126
82935
  capScope: "system",
@@ -95072,36 +95881,22 @@ function buildHmAdoptionProvider(deps) {
95072
95881
  }
95073
95882
  };
95074
95883
  }
95075
- //#endregion
95076
- //#region src/homematic-ccu-discovery.ts
95077
- /** `prefilledConfig` flag: the scan opens the integration form instead of adopting a device. */
95078
- var HOMEMATIC_SETUP_INTEGRATION_KEY = "setupIntegration";
95079
- var JSON_RPC_PATH$2 = "/api/homematic.cgi";
95080
- /** How long one host gets to answer before the sweep moves on. */
95081
- var HOMEMATIC_DISCOVERY_TIMEOUT_MS = 1200;
95082
- /** Upper bound on the hosts one scan probes, so a wide interface list cannot flood the LAN. */
95083
- var MAX_HOSTS = 1024;
95084
- var fetchPost = async (url, body, timeoutMs) => {
95085
- const response = await fetch(url, {
95086
- method: "POST",
95087
- headers: { "Content-Type": "application/json" },
95088
- body,
95089
- signal: AbortSignal.timeout(timeoutMs)
95090
- });
95091
- return {
95092
- status: response.status,
95093
- text: await response.text()
95094
- };
95095
- };
95096
- function osAddresses() {
95097
- const out = [];
95098
- for (const list of Object.values((0, node_os.networkInterfaces)())) for (const a of list ?? []) out.push({
95099
- family: String(a.family),
95100
- address: a.address,
95101
- internal: a.internal
95102
- });
95103
- return out;
95104
- }
95884
+ /** The question the WebUI asks before it shows a login; the engine sends it to every host once. */
95885
+ var HOMEMATIC_DISCOVERY_MODES = [{
95886
+ kind: "http-probe",
95887
+ port: 80,
95888
+ path: "/api/homematic.cgi",
95889
+ method: "POST",
95890
+ bodyText: JSON.stringify({
95891
+ method: "CCU.getAuthEnabled",
95892
+ params: {},
95893
+ jsonrpc: "1.1",
95894
+ id: 0
95895
+ }),
95896
+ contentType: "application/json",
95897
+ tls: false,
95898
+ acceptStatus: [200]
95899
+ }];
95105
95900
  /**
95106
95901
  * Read the answer of `CCU.getAuthEnabled`, the question the WebUI asks before
95107
95902
  * it shows a login. A CCU answers a boolean; one that already requires a login
@@ -95128,63 +95923,26 @@ function parseAuthEnabledAnswer(status, text) {
95128
95923
  if (result === false || result === "false") return { authEnabled: false };
95129
95924
  return null;
95130
95925
  }
95131
- /**
95132
- * The hosts to ask: every address of the /24 of each external IPv4 address of
95133
- * this machine, minus the network and broadcast addresses and this machine itself.
95134
- */
95135
- function lanHostsToProbe(addresses) {
95136
- const own = /* @__PURE__ */ new Set();
95137
- const subnets = [];
95138
- for (const a of addresses) {
95139
- if (a.internal || a.family !== "IPv4" && a.family !== "4") continue;
95140
- own.add(a.address);
95141
- const prefix = a.address.split(".").slice(0, 3).join(".");
95142
- if (prefix.split(".").length === 3 && !subnets.includes(prefix)) subnets.push(prefix);
95143
- }
95144
- const hosts = [];
95145
- for (const prefix of subnets) for (let last = 1; last <= 254; last++) {
95146
- const host = `${prefix}.${last}`;
95147
- if (!own.has(host)) hosts.push(host);
95148
- }
95149
- return hosts.slice(0, MAX_HOSTS);
95150
- }
95151
95926
  /** Slugify a host into a readable, stable id fragment. */
95152
95927
  function slugifyHost(value) {
95153
95928
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
95154
95929
  }
95155
- /**
95156
- * Sweep the local /24 networks for CCU3 / RaspberryMatic WebUIs: each host is
95157
- * asked `CCU.getAuthEnabled` on the JSON-RPC endpoint, with a short timeout and
95158
- * a bounded number of probes in flight.
95159
- */
95160
- async function browseHomematicCcus(options = {}) {
95161
- const { addresses = osAddresses, post = fetchPost, concurrency = 64, timeoutMs = HOMEMATIC_DISCOVERY_TIMEOUT_MS } = options;
95162
- const hosts = lanHostsToProbe(addresses());
95163
- const body = JSON.stringify({
95164
- method: "CCU.getAuthEnabled",
95165
- params: {},
95166
- jsonrpc: "1.1",
95167
- id: 0
95168
- });
95930
+ /** CCUs among the engine's http-probe hits: the hosts whose answer is the JSON-RPC one. */
95931
+ function ccusFromHits(hits) {
95169
95932
  const found = [];
95170
- let next = 0;
95171
- const worker = async () => {
95172
- for (;;) {
95173
- const host = hosts[next++];
95174
- if (host === void 0) return;
95175
- try {
95176
- const reply = await post(`http://${host}${JSON_RPC_PATH$2}`, body, timeoutMs);
95177
- const answer = parseAuthEnabledAnswer(reply.status, reply.text);
95178
- if (answer !== null) found.push({
95179
- host,
95180
- name: `Homematic CCU (${host})`,
95181
- id: slugifyHost(host),
95182
- authEnabled: answer.authEnabled
95183
- });
95184
- } catch {}
95185
- }
95186
- };
95187
- await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
95933
+ const seen = /* @__PURE__ */ new Set();
95934
+ for (const hit of hits) {
95935
+ if (hit.http === void 0 || seen.has(hit.host)) continue;
95936
+ const answer = parseAuthEnabledAnswer(hit.http.status, hit.http.bodyText);
95937
+ if (answer === null) continue;
95938
+ seen.add(hit.host);
95939
+ found.push({
95940
+ host: hit.host,
95941
+ name: `Homematic CCU (${hit.host})`,
95942
+ id: slugifyHost(hit.host),
95943
+ authEnabled: answer.authEnabled
95944
+ });
95945
+ }
95188
95946
  return found.sort((a, b) => a.host.localeCompare(b.host, void 0, { numeric: true }));
95189
95947
  }
95190
95948
  /** CCUs whose host is already stored on an integration are not proposed again. */
@@ -95195,11 +95953,10 @@ function proposeHomematicCcus(found, existingHosts) {
95195
95953
  function homematicDiscoveryCandidates(proposals) {
95196
95954
  return proposals.map((found) => ({
95197
95955
  stableId: `homematic:${found.id}`,
95956
+ type: DeviceType.Gateway,
95198
95957
  suggestedName: found.name,
95199
- prefilledConfig: {
95200
- host: found.host,
95201
- [HOMEMATIC_SETUP_INTEGRATION_KEY]: true
95202
- }
95958
+ prefilledConfig: { host: found.host },
95959
+ setupIntegration: true
95203
95960
  }));
95204
95961
  }
95205
95962
  function homematicDiscoverySummary(foundCount, proposedCount) {
@@ -118491,14 +119248,15 @@ var HomematicProviderAddon = class extends BaseDeviceProvider {
118491
119248
  ],
118492
119249
  customActions: homematicActions,
118493
119250
  actionHandlers: { discoverCcu: async () => {
118494
- const { proposals, summary } = await this.discoverCcus();
119251
+ const { candidates } = await this.ctx.api.deviceManager.discoverProvider.mutate({ addonId: this.ctx.id });
119252
+ const hosts = candidates.map((candidate) => ({
119253
+ host: String(candidate.prefilledConfig["host"] ?? ""),
119254
+ name: candidate.suggestedName,
119255
+ id: candidate.stableId.replace(/^homematic:/, "")
119256
+ }));
118495
119257
  return {
118496
- hosts: proposals.map((found) => ({
118497
- host: found.host,
118498
- name: found.name,
118499
- id: found.id
118500
- })),
118501
- summary
119258
+ hosts,
119259
+ summary: homematicDiscoverySummary(hosts.length, hosts.length)
118502
119260
  };
118503
119261
  } }
118504
119262
  };
@@ -118533,18 +119291,21 @@ var HomematicProviderAddon = class extends BaseDeviceProvider {
118533
119291
  async supportsManualCreation() {
118534
119292
  return false;
118535
119293
  }
118536
- /** LAN scan for CCU3 / RaspberryMatic WebUIs over the local /24 networks of this (hub-only) addon's host. */
119294
+ /** A CCU is found by the scan engine's http-probe; this addon classifies the hits. */
118537
119295
  async supportsDiscovery() {
118538
119296
  return true;
118539
119297
  }
118540
- async discoverDevices() {
118541
- const { proposals } = await this.discoverCcus();
118542
- return homematicDiscoveryCandidates(proposals).map((candidate) => ({
118543
- stableId: candidate.stableId,
118544
- type: DeviceType.Gateway,
118545
- suggestedName: candidate.suggestedName,
118546
- prefilledConfig: { ...candidate.prefilledConfig }
118547
- }));
119298
+ async getDiscoveryModes() {
119299
+ return HOMEMATIC_DISCOVERY_MODES;
119300
+ }
119301
+ async classifyDiscoveryHits(input) {
119302
+ const found = ccusFromHits(input.hits);
119303
+ const proposals = proposeHomematicCcus(found, await this.knownCcuHosts());
119304
+ this.ctx.logger.info("Homematic CCU discovery finished", { meta: {
119305
+ found: found.length,
119306
+ proposed: proposals.length
119307
+ } });
119308
+ return homematicDiscoveryCandidates(proposals);
118548
119309
  }
118549
119310
  async adoptDiscoveredDevice(_input) {
118550
119311
  throw new Error("A Homematic CCU is added from the integration form");
@@ -118555,23 +119316,6 @@ var HomematicProviderAddon = class extends BaseDeviceProvider {
118555
119316
  async onCreateDevice(_type, _config) {
118556
119317
  throw new Error("Homematic devices are adopted from the integration, not created manually");
118557
119318
  }
118558
- async discoverCcus() {
118559
- let found = [];
118560
- try {
118561
- found = await browseHomematicCcus();
118562
- } catch (err) {
118563
- this.ctx.logger.warn("Homematic CCU discovery failed", { meta: { error: errMsg(err) } });
118564
- }
118565
- const proposals = proposeHomematicCcus(found, await this.knownCcuHosts());
118566
- this.ctx.logger.info("Homematic CCU discovery finished", { meta: {
118567
- found: found.length,
118568
- proposed: proposals.length
118569
- } });
118570
- return {
118571
- proposals,
118572
- summary: homematicDiscoverySummary(found.length, proposals.length)
118573
- };
118574
- }
118575
119319
  async knownCcuHosts() {
118576
119320
  try {
118577
119321
  const all = await this.ctx.api.integrations.list.query();