@camstack/addon-provider-velux 0.2.150 → 0.2.151

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
@@ -33,7 +33,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
  //#endregion
34
34
  let node_tls = require("node:tls");
35
35
  let timers_promises = require("timers/promises");
36
- let node_dgram = require("node:dgram");
37
36
  //#region ../types/dist/event-category-DmZDPDf_.mjs
38
37
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
39
38
  EventCategory["SystemBoot"] = "system.boot";
@@ -1386,7 +1385,7 @@ var _emoji$1 = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`;
1386
1385
  function emoji() {
1387
1386
  return new RegExp(_emoji$1, "u");
1388
1387
  }
1389
- var ipv4$1 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
1388
+ var ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
1390
1389
  var ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
1391
1390
  var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
1392
1391
  var cidrv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/;
@@ -2061,7 +2060,7 @@ var $ZodISODuration = /*@__PURE__*/ $constructor("$ZodISODuration", (inst, def)
2061
2060
  $ZodStringFormat.init(inst, def);
2062
2061
  });
2063
2062
  var $ZodIPv4 = /*@__PURE__*/ $constructor("$ZodIPv4", (inst, def) => {
2064
- def.pattern ?? (def.pattern = ipv4$1);
2063
+ def.pattern ?? (def.pattern = ipv4);
2065
2064
  $ZodStringFormat.init(inst, def);
2066
2065
  inst._zod.bag.format = `ipv4`;
2067
2066
  });
@@ -5490,7 +5489,7 @@ var ZodIssueCode = {
5490
5489
  var ZodFirstPartyTypeKind;
5491
5490
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5492
5491
  //#endregion
5493
- //#region ../types/dist/sleep-63gM_1JR.mjs
5492
+ //#region ../types/dist/sleep-CeyhrcOk.mjs
5494
5493
  /**
5495
5494
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5496
5495
  * window to float samples (D455).
@@ -7557,6 +7556,7 @@ var BaseAddon = class {
7557
7556
  * a warning and falls back rather than crashing boot.
7558
7557
  */
7559
7558
  state(key, schema, fallback) {
7559
+ this._declaredStateKeys.add(key);
7560
7560
  return createDurableState({
7561
7561
  key,
7562
7562
  schema,
@@ -7572,6 +7572,96 @@ var BaseAddon = class {
7572
7572
  } })
7573
7573
  });
7574
7574
  }
7575
+ /** Durable-state keys this process registered through {@link state}. */
7576
+ _declaredStateKeys = /* @__PURE__ */ new Set();
7577
+ /**
7578
+ * Stale-data audit: every global-settings key this addon still declares —
7579
+ * constructor defaults, settings-schema fields (layout containers included),
7580
+ * durable-state keys and the root of each `secretStatePaths` entry. Answered
7581
+ * here, in the addon's own process, so the hub keeps no copy of it.
7582
+ */
7583
+ async getDeclaredSettingsKeys() {
7584
+ return { keys: [...this.declaredKeySet()].toSorted() };
7585
+ }
7586
+ declaredKeySet() {
7587
+ const keys = new Set(Object.keys(this.defaults));
7588
+ const schema = this.globalSettingsSchema();
7589
+ if (schema) for (const section of schema.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
7590
+ for (const key of this._declaredStateKeys) keys.add(key);
7591
+ for (const path of this.secretStatePaths) {
7592
+ const root = path.split(/[.[]/, 1)[0];
7593
+ if (root) keys.add(root);
7594
+ }
7595
+ return keys;
7596
+ }
7597
+ /**
7598
+ * Stale-data audit: remove stored global-settings keys the addon no longer
7599
+ * declares, and clear the keychain values their markers held — except a value
7600
+ * a surviving key still points at. A declared or absent key is refused.
7601
+ */
7602
+ async unsetStoredSettingsKeys(keys) {
7603
+ const settings = this._ctx?.settings;
7604
+ const raw = this.rawSettings();
7605
+ if (!settings || !raw) return {
7606
+ removed: [],
7607
+ refused: keys.map((key) => ({
7608
+ key,
7609
+ reason: "no settings store"
7610
+ })),
7611
+ secretsCleared: 0
7612
+ };
7613
+ let stored;
7614
+ try {
7615
+ stored = await raw.readAddonStore();
7616
+ } catch (error) {
7617
+ const reason = `store unreadable: ${error instanceof Error ? error.message : "unknown"}`;
7618
+ return {
7619
+ removed: [],
7620
+ refused: keys.map((key) => ({
7621
+ key,
7622
+ reason
7623
+ })),
7624
+ secretsCleared: 0
7625
+ };
7626
+ }
7627
+ const declared = this.declaredKeySet();
7628
+ const removed = [];
7629
+ const refused = [];
7630
+ for (const key of keys) {
7631
+ const base = key.split("@", 1)[0] ?? key;
7632
+ if (declared.has(base)) refused.push({
7633
+ key,
7634
+ reason: "still declared by the addon"
7635
+ });
7636
+ else if (!(key in stored)) refused.push({
7637
+ key,
7638
+ reason: "not stored"
7639
+ });
7640
+ else removed.push(key);
7641
+ }
7642
+ if (removed.length === 0) return {
7643
+ removed,
7644
+ refused,
7645
+ secretsCleared: 0
7646
+ };
7647
+ const kept = /* @__PURE__ */ new Set();
7648
+ const held = /* @__PURE__ */ new Set();
7649
+ for (const [key, value] of Object.entries(stored)) {
7650
+ const target = removed.includes(key) ? held : kept;
7651
+ for (const ref of collectSecretMarkerRefs(value)) target.add(ref);
7652
+ }
7653
+ const refsToClear = [...held].filter((ref) => !kept.has(ref));
7654
+ const patch = {};
7655
+ for (const key of removed) patch[key] = void 0;
7656
+ await settings.writeAddonStore(patch);
7657
+ const secretsCleared = refsToClear.length > 0 && this._ctx?.secrets ? await this._ctx.secrets.delete(refsToClear) : 0;
7658
+ await this.resolveConfig();
7659
+ return {
7660
+ removed,
7661
+ refused,
7662
+ secretsCleared
7663
+ };
7664
+ }
7575
7665
  /** Per-device variant of {@link state}, backed by the per-device store. */
7576
7666
  deviceState(deviceId, key, schema, fallback) {
7577
7667
  return createDurableState({
@@ -7842,6 +7932,21 @@ function dropPerNodeFields(fields) {
7842
7932
  }
7843
7933
  return kept;
7844
7934
  }
7935
+ function collectFieldKeys(fields) {
7936
+ const collected = [];
7937
+ for (const field of fields) {
7938
+ if (field.type === "group") {
7939
+ collected.push(...collectFieldKeys(field.fields));
7940
+ continue;
7941
+ }
7942
+ if (field.type === "sub-tabs") {
7943
+ for (const tab of field.tabs) collected.push(...collectFieldKeys(tab.fields));
7944
+ continue;
7945
+ }
7946
+ if ("key" in field && typeof field.key === "string") collected.push(field.key);
7947
+ }
7948
+ return collected;
7949
+ }
7845
7950
  function collectPerNodeFieldKeys(fields) {
7846
7951
  const collected = [];
7847
7952
  for (const field of fields) {
@@ -16113,8 +16218,172 @@ object({
16113
16218
  quantity: ReadingQuantitySchema,
16114
16219
  unit: string().min(1)
16115
16220
  });
16116
- /** How a leaf compares a device field to a value. Derived from the field's
16117
- * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
16221
+ new Set([
16222
+ {
16223
+ id: "person",
16224
+ name: "Person"
16225
+ },
16226
+ {
16227
+ id: "vehicle",
16228
+ name: "Vehicle"
16229
+ },
16230
+ {
16231
+ id: "animal",
16232
+ name: "Animal"
16233
+ },
16234
+ {
16235
+ id: "package",
16236
+ name: "Package"
16237
+ }
16238
+ ].map((l) => l.id));
16239
+ var COCO_TO_MACRO = {
16240
+ mapping: {
16241
+ person: "person",
16242
+ bicycle: "vehicle",
16243
+ car: "vehicle",
16244
+ motorcycle: "vehicle",
16245
+ airplane: "vehicle",
16246
+ bus: "vehicle",
16247
+ train: "vehicle",
16248
+ truck: "vehicle",
16249
+ boat: "vehicle",
16250
+ bird: "animal",
16251
+ cat: "animal",
16252
+ dog: "animal",
16253
+ horse: "animal",
16254
+ sheep: "animal",
16255
+ cow: "animal",
16256
+ elephant: "animal",
16257
+ bear: "animal",
16258
+ zebra: "animal",
16259
+ giraffe: "animal",
16260
+ suitcase: "package",
16261
+ backpack: "package",
16262
+ handbag: "package"
16263
+ },
16264
+ preserveOriginal: false
16265
+ };
16266
+ /**
16267
+ * WHAT a detection-shaped leaf is about: one MACRO class (person, vehicle,
16268
+ * animal, package) plus an OPTIONAL label that narrows it.
16269
+ *
16270
+ * The taxonomy is `COCO_TO_MACRO` (and `CLASS_MAP_MACRO_TARGETS`) — never a
16271
+ * second list. The label that fits depends on the macro class:
16272
+ *
16273
+ * - `animal` → a sub-class (`dog`, `cat`, …);
16274
+ * - `person` → a known face (an identity of the `face-gallery`);
16275
+ * - `vehicle` → a plate (its text) or a sub-class (`car`, `truck`, …);
16276
+ * - `package` → a sub-class.
16277
+ *
16278
+ * `other` exists only so a class the taxonomy does not know (a custom model's
16279
+ * `forklift`) survives the conversion of a stored recipe without widening.
16280
+ *
16281
+ * A label is decided LATE (D683): face recognition and LPR land after the
16282
+ * detection starts. The track-lifecycle `update` event carries the newly
16283
+ * resolved `label` / `plateText`, which is why a leaf with a label fires when
16284
+ * the subject GETS it — not at the first detection.
16285
+ */
16286
+ var SubClassLabelSchema = object({
16287
+ kind: literal("sub-class"),
16288
+ className: string().min(1)
16289
+ });
16290
+ var IdentityLabelSchema = object({
16291
+ kind: literal("identity"),
16292
+ /** The `face-gallery` identity id (stable across a rename). */
16293
+ identityId: string().min(1),
16294
+ /** The identity's name — what a track-lifecycle event carries as `label`. */
16295
+ name: string().min(1)
16296
+ });
16297
+ var PlateLabelSchema = object({
16298
+ kind: literal("plate"),
16299
+ text: string().min(1)
16300
+ });
16301
+ function macroOf$1(className) {
16302
+ return COCO_TO_MACRO.mapping[className.toLowerCase()] ?? className.toLowerCase();
16303
+ }
16304
+ /** A sub-class label must belong to the macro it is under. */
16305
+ function subClassBelongs(macro, label) {
16306
+ return label === void 0 || macroOf$1(label.className) === macro;
16307
+ }
16308
+ var subClassOf = (macro) => SubClassLabelSchema.refine((l) => subClassBelongs(macro, l), { message: `that class is not a kind of ${macro} in the detection taxonomy` });
16309
+ var DetectionSubjectSchema = discriminatedUnion("macro", [
16310
+ object({
16311
+ macro: literal("animal"),
16312
+ label: subClassOf("animal").optional()
16313
+ }),
16314
+ object({
16315
+ macro: literal("person"),
16316
+ label: IdentityLabelSchema.optional()
16317
+ }),
16318
+ object({
16319
+ macro: literal("vehicle"),
16320
+ label: discriminatedUnion("kind", [SubClassLabelSchema.refine((l) => subClassBelongs("vehicle", l), { message: "that class is not a kind of vehicle in the detection taxonomy" }), PlateLabelSchema]).optional()
16321
+ }),
16322
+ object({
16323
+ macro: literal("package"),
16324
+ label: subClassOf("package").optional()
16325
+ }),
16326
+ object({
16327
+ macro: literal("other"),
16328
+ className: string().min(1)
16329
+ })
16330
+ ]);
16331
+ /**
16332
+ * The subject of a PARKED object. The stationary registry's event carries a
16333
+ * class and a zone, no face and no plate — an identity or plate label there
16334
+ * could never match, so the schema refuses it instead of letting it sit dead.
16335
+ */
16336
+ 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" });
16337
+ /**
16338
+ * A stored class string as a subject: a macro name stays a macro; a raw class
16339
+ * the taxonomy files under a macro becomes that macro plus the sub-class label
16340
+ * (`dog` → animal / dog); anything else is `other`, kept verbatim.
16341
+ */
16342
+ function subjectFromClassName(className) {
16343
+ const name = className.trim().toLowerCase();
16344
+ const macro = COCO_TO_MACRO.mapping[name];
16345
+ if (new Set(Object.values(COCO_TO_MACRO.mapping)).has(name)) return macroOnly(name);
16346
+ if (macro === void 0) return {
16347
+ macro: "other",
16348
+ className
16349
+ };
16350
+ const label = {
16351
+ kind: "sub-class",
16352
+ className: name
16353
+ };
16354
+ switch (macro) {
16355
+ case "animal": return {
16356
+ macro: "animal",
16357
+ label
16358
+ };
16359
+ case "vehicle": return {
16360
+ macro: "vehicle",
16361
+ label
16362
+ };
16363
+ case "package": return {
16364
+ macro: "package",
16365
+ label
16366
+ };
16367
+ default: return {
16368
+ macro: "other",
16369
+ className
16370
+ };
16371
+ }
16372
+ }
16373
+ function macroOnly(macro) {
16374
+ switch (macro) {
16375
+ case "animal": return { macro: "animal" };
16376
+ case "person": return { macro: "person" };
16377
+ case "vehicle": return { macro: "vehicle" };
16378
+ case "package": return { macro: "package" };
16379
+ default: return {
16380
+ macro: "other",
16381
+ className: macro
16382
+ };
16383
+ }
16384
+ }
16385
+ /** How a state leaf compares a device field to a value. Derived from the
16386
+ * field's `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
16118
16387
  var AutomationConditionOperatorSchema = _enum([
16119
16388
  "eq",
16120
16389
  "ne",
@@ -16125,12 +16394,76 @@ var AutomationConditionOperatorSchema = _enum([
16125
16394
  "contains",
16126
16395
  "in"
16127
16396
  ]);
16128
- var AutomationConditionLeafSchema = object({
16129
- kind: literal("condition"),
16397
+ /**
16398
+ * D8 compliance, and it is the reason a "becomes" leaf is not merely an event
16399
+ * subscription: it evaluates against the **state mirror**, which is reconciled,
16400
+ * and an event only WAKES the evaluation. A dropped event therefore DELAYS a
16401
+ * fire; it does not lose it.
16402
+ */
16403
+ var DeviceBecomesLeafSchema = object({
16404
+ kind: literal("device-becomes"),
16405
+ deviceId: number().int().nonnegative(),
16406
+ cap: string().min(1),
16407
+ fieldPath: string().min(1),
16408
+ /** Fire when the field takes this value. Omit to fire on any change. */
16409
+ becomes: union([
16410
+ string(),
16411
+ number(),
16412
+ boolean()
16413
+ ]).optional(),
16414
+ /** Only on a CHANGE of value, not on every re-report. */
16415
+ edge: boolean().optional(),
16416
+ /** The new value must hold this long before it counts. */
16417
+ forMs: number().int().min(0).max(864e5).optional(),
16418
+ /** Collapse a burst into one occurrence. */
16419
+ debounceMs: number().int().min(0).max(6e5).optional()
16420
+ });
16421
+ var DeviceEventLeafSchema = object({
16422
+ kind: literal("device-event"),
16423
+ /** An `EventCategory` value. */
16424
+ category: string().min(1),
16425
+ deviceId: number().int().nonnegative().optional()
16426
+ });
16427
+ var ScheduleLeafSchema = object({
16428
+ kind: literal("schedule"),
16429
+ cron: string().min(1).max(120)
16430
+ });
16431
+ var ManualLeafSchema = object({ kind: literal("manual") });
16432
+ var IntervalLeafSchema = object({
16433
+ kind: literal("interval"),
16434
+ minutes: number().int().min(1).max(1440)
16435
+ });
16436
+ var DetectionLeafSchema = object({
16437
+ kind: literal("detection"),
16438
+ /** One macro class each, optionally narrowed by a label; a track of any listed subject fires. */
16439
+ subjects: array(DetectionSubjectSchema).min(1),
16440
+ deviceIds: array(number().int().nonnegative()).min(1),
16441
+ /** The track must have visited at least one of these zones. */
16442
+ zones: array(string().min(1)).min(1).optional(),
16443
+ /** Best-so-far confidence the track must reach. */
16444
+ minScore: number().min(0).max(1).optional()
16445
+ });
16446
+ var StationaryLeafSchema = object({
16447
+ kind: literal("stationary"),
16448
+ deviceIds: array(number().int().nonnegative()).min(1),
16449
+ subjects: array(StationarySubjectSchema).min(1).optional(),
16450
+ zones: array(string().min(1)).min(1).optional(),
16451
+ /** `appeared`: it became parked. `departed`: the registry retired it. */
16452
+ phase: _enum(["appeared", "departed"])
16453
+ });
16454
+ /** "is": one device field compared to one value, NOW. */
16455
+ var DeviceIsLeafSchema = object({
16456
+ kind: literal("device-is"),
16130
16457
  deviceId: number().int().nonnegative(),
16131
16458
  cap: string().min(1),
16132
16459
  fieldPath: string().min(1),
16133
16460
  operator: AutomationConditionOperatorSchema,
16461
+ /**
16462
+ * The value. A number on a quantity field is stored in the field's base unit
16463
+ * (D944); a quantity value is the WRITE form, which the save boundary
16464
+ * replaces with the converted number and `unit`. A leaf that still carries
16465
+ * one is never evaluated (it fails).
16466
+ */
16134
16467
  value: union([
16135
16468
  string(),
16136
16469
  number(),
@@ -16138,24 +16471,26 @@ var AutomationConditionLeafSchema = object({
16138
16471
  array(union([string(), number()])),
16139
16472
  QuantityValueSchema
16140
16473
  ]),
16474
+ /** The unit the person picked for `value` (display only; `value` is in the base unit). */
16141
16475
  unit: string().min(1).optional()
16142
16476
  });
16143
- var AutomationConditionStationaryPresentSchema = object({
16477
+ /**
16478
+ * True when the camera currently has a PARKED object matching `subjects` in at
16479
+ * least one of `zones`. Reads the zone-analytics snapshot through the same
16480
+ * mirror as every other state leaf, so a dropped event never decides it (D49).
16481
+ */
16482
+ var StationaryPresentLeafSchema = object({
16144
16483
  kind: literal("stationary-present"),
16145
16484
  deviceId: number().int().nonnegative(),
16146
- /** Class names; a macro class matches every raw class that maps to it. */
16147
- classes: array(string().min(1)).min(1).optional(),
16148
- /** The parked object must be in at least one of these zones. */
16485
+ subjects: array(StationarySubjectSchema).min(1).optional(),
16149
16486
  zones: array(string().min(1)).min(1).optional()
16150
16487
  });
16151
16488
  /**
16152
- * The expression leaf, declared as a plain object rather than an intersection
16153
- * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
16154
- * read `kind` off each option, and an intersection hides it. The author-time
16155
- * validation is the SAME function `ExpressionSourceSchema` runs, so the two
16156
- * cannot drift — an expression that one accepts, the other accepts.
16489
+ * The escape hatch: the bounded expression engine over named device bindings.
16490
+ * Declared as a plain object so a discriminated union can read `kind`; the
16491
+ * author-time validation is the SAME function `ExpressionSourceSchema` runs.
16157
16492
  */
16158
- var AutomationConditionExpressionSchema = object({
16493
+ var ExpressionLeafSchema = object({
16159
16494
  kind: literal("expression"),
16160
16495
  expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
16161
16496
  bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
@@ -16167,55 +16502,62 @@ var AutomationConditionExpressionSchema = object({
16167
16502
  path: ["expr"]
16168
16503
  });
16169
16504
  });
16170
- var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
16505
+ var AutomationWhenSchema = lazy(() => discriminatedUnion("kind", [
16171
16506
  object({
16172
16507
  kind: literal("all"),
16173
- children: array(AutomationConditionSchema)
16508
+ children: array(AutomationWhenSchema)
16174
16509
  }),
16175
16510
  object({
16176
16511
  kind: literal("any"),
16177
- children: array(AutomationConditionSchema)
16512
+ children: array(AutomationWhenSchema)
16178
16513
  }),
16179
16514
  object({
16180
16515
  kind: literal("not"),
16181
- child: AutomationConditionSchema
16516
+ child: AutomationWhenSchema
16182
16517
  }),
16183
- AutomationConditionLeafSchema,
16184
- AutomationConditionExpressionSchema,
16185
- AutomationConditionStationaryPresentSchema
16518
+ DeviceBecomesLeafSchema,
16519
+ DeviceEventLeafSchema,
16520
+ ScheduleLeafSchema,
16521
+ ManualLeafSchema,
16522
+ IntervalLeafSchema,
16523
+ DetectionLeafSchema,
16524
+ StationaryLeafSchema,
16525
+ DeviceIsLeafSchema,
16526
+ StationaryPresentLeafSchema,
16527
+ ExpressionLeafSchema
16186
16528
  ]));
16187
16529
  /**
16188
- * What starts a run.
16189
- *
16190
- * D8 compliance, and it is the reason `device-state` is not merely an event
16191
- * subscription: the trigger evaluates against the **state mirror**, which is
16192
- * reconciled, and an event only WAKES the evaluation. A dropped event therefore
16193
- * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
16194
- * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
16195
- * not.
16196
- */
16197
- var AutomationTriggerSchema = discriminatedUnion("kind", [
16530
+ * The recipe shape before the single `when` tree: a flat list of triggers
16531
+ * (OR'd, each an instant) and an optional condition tree. Stored recipes —
16532
+ * the `AUTOMATION_RECIPE` literal of every block saved so far, and whatever an
16533
+ * older client still posts — are in this shape, so it stays READABLE here, and
16534
+ * {@link convertLegacyRecipe} turns it into the new one, losslessly and one
16535
+ * way. Nothing writes this shape any more.
16536
+ *
16537
+ * `{ triggers: [t1…tn], conditions?: C }`
16538
+ * → `when = all(any(t1…tn), C)`, or `any(t1…tn)` without `C`;
16539
+ * a single trigger is not wrapped in `any`.
16540
+ * a `condition` leaf → a `device-is` leaf (the "is" verb);
16541
+ * a `device-state` trigger → a `device-becomes` leaf (the "becomes" verb);
16542
+ * `classes: ['dog']` → the macro `animal` with the label `dog`.
16543
+ */
16544
+ var LegacyTriggerSchema = discriminatedUnion("kind", [
16198
16545
  object({
16199
16546
  kind: literal("device-state"),
16200
16547
  deviceId: number().int().nonnegative(),
16201
16548
  cap: string().min(1),
16202
16549
  fieldPath: string().min(1),
16203
- /** Fire when the field takes this value. Omit to fire on any change. */
16204
16550
  becomes: union([
16205
16551
  string(),
16206
16552
  number(),
16207
16553
  boolean()
16208
16554
  ]).optional(),
16209
- /** Only on a CHANGE of value, not on every re-report. */
16210
16555
  edge: boolean().optional(),
16211
- /** The condition must hold this long before the run starts. */
16212
16556
  forMs: number().int().min(0).max(864e5).optional(),
16213
- /** Collapse a burst into one run. */
16214
16557
  debounceMs: number().int().min(0).max(6e5).optional()
16215
16558
  }),
16216
16559
  object({
16217
16560
  kind: literal("device-event"),
16218
- /** An `EventCategory` value. */
16219
16561
  category: string().min(1),
16220
16562
  deviceId: number().int().nonnegative().optional()
16221
16563
  }),
@@ -16226,25 +16568,16 @@ var AutomationTriggerSchema = discriminatedUnion("kind", [
16226
16568
  object({ kind: literal("manual") }),
16227
16569
  object({
16228
16570
  kind: literal("detection"),
16229
- /** Class names. A macro class (`animal`, `vehicle`, `person`, `package`)
16230
- * matches every raw class that maps to it; any other name matches
16231
- * exactly. */
16232
16571
  classes: array(string().min(1)).min(1),
16233
16572
  deviceIds: array(number().int().nonnegative()).min(1),
16234
- /** The track must have visited at least one of these zones. */
16235
16573
  zones: array(string().min(1)).min(1).optional(),
16236
- /** Best-so-far confidence the track must reach. */
16237
16574
  minScore: number().min(0).max(1).optional()
16238
16575
  }),
16239
16576
  object({
16240
16577
  kind: literal("stationary"),
16241
16578
  deviceIds: array(number().int().nonnegative()).min(1),
16242
- /** Class names; a macro class matches like the detection trigger. */
16243
16579
  classes: array(string().min(1)).min(1).optional(),
16244
- /** The parked object must be in at least one of these zones. */
16245
16580
  zones: array(string().min(1)).min(1).optional(),
16246
- /** `appeared`: it became parked. `departed`: the registry retired it.
16247
- * The vocabulary of `PipelineAnalyticsStationaryChanged`. */
16248
16581
  phase: _enum(["appeared", "departed"])
16249
16582
  }),
16250
16583
  object({
@@ -16252,6 +16585,143 @@ var AutomationTriggerSchema = discriminatedUnion("kind", [
16252
16585
  minutes: number().int().min(1).max(1440)
16253
16586
  })
16254
16587
  ]);
16588
+ var LegacyConditionLeafSchema = object({
16589
+ kind: literal("condition"),
16590
+ deviceId: number().int().nonnegative(),
16591
+ cap: string().min(1),
16592
+ fieldPath: string().min(1),
16593
+ operator: AutomationConditionOperatorSchema,
16594
+ value: union([
16595
+ string(),
16596
+ number(),
16597
+ boolean(),
16598
+ array(union([string(), number()])),
16599
+ QuantityValueSchema
16600
+ ]),
16601
+ unit: string().min(1).optional()
16602
+ });
16603
+ var LegacyStationaryPresentSchema = object({
16604
+ kind: literal("stationary-present"),
16605
+ deviceId: number().int().nonnegative(),
16606
+ classes: array(string().min(1)).min(1).optional(),
16607
+ zones: array(string().min(1)).min(1).optional()
16608
+ });
16609
+ var LegacyExpressionSchema = object({
16610
+ kind: literal("expression"),
16611
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
16612
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
16613
+ }).superRefine((src, ctx) => {
16614
+ const err = validateExpressionSource(src);
16615
+ if (err !== null) ctx.addIssue({
16616
+ code: "custom",
16617
+ message: err,
16618
+ path: ["expr"]
16619
+ });
16620
+ });
16621
+ var LegacyConditionSchema = lazy(() => discriminatedUnion("kind", [
16622
+ object({
16623
+ kind: literal("all"),
16624
+ children: array(LegacyConditionSchema)
16625
+ }),
16626
+ object({
16627
+ kind: literal("any"),
16628
+ children: array(LegacyConditionSchema)
16629
+ }),
16630
+ object({
16631
+ kind: literal("not"),
16632
+ child: LegacyConditionSchema
16633
+ }),
16634
+ LegacyConditionLeafSchema,
16635
+ LegacyExpressionSchema,
16636
+ LegacyStationaryPresentSchema
16637
+ ]));
16638
+ function convertTrigger(trigger) {
16639
+ switch (trigger.kind) {
16640
+ case "device-state": return {
16641
+ ...trigger,
16642
+ kind: "device-becomes"
16643
+ };
16644
+ case "detection": {
16645
+ const { classes, ...rest } = trigger;
16646
+ return {
16647
+ ...rest,
16648
+ subjects: classes.map(subjectFromClassName)
16649
+ };
16650
+ }
16651
+ case "stationary": {
16652
+ const { classes, ...rest } = trigger;
16653
+ return {
16654
+ ...rest,
16655
+ ...classes === void 0 ? {} : { subjects: classes.map(subjectFromClassName) }
16656
+ };
16657
+ }
16658
+ default: return trigger;
16659
+ }
16660
+ }
16661
+ /** An old condition tree as a `when` subtree: `condition` → "is", classes → subjects. */
16662
+ function convertLegacyCondition(condition) {
16663
+ switch (condition.kind) {
16664
+ case "condition": return {
16665
+ ...condition,
16666
+ kind: "device-is"
16667
+ };
16668
+ case "stationary-present": {
16669
+ const { classes, ...rest } = condition;
16670
+ return {
16671
+ ...rest,
16672
+ ...classes === void 0 ? {} : { subjects: classes.map(subjectFromClassName) }
16673
+ };
16674
+ }
16675
+ case "expression": return condition;
16676
+ case "not": return {
16677
+ kind: "not",
16678
+ child: convertLegacyCondition(condition.child)
16679
+ };
16680
+ case "all":
16681
+ case "any": return {
16682
+ kind: condition.kind,
16683
+ children: condition.children.map(convertLegacyCondition)
16684
+ };
16685
+ }
16686
+ }
16687
+ /** `all(any(t1…tn), C)`, or `any(t1…tn)`; a single trigger is not wrapped in `any`. */
16688
+ function convertLegacyWhen(half) {
16689
+ const leaves = half.triggers.map(convertTrigger);
16690
+ const first = leaves[0];
16691
+ const triggers = leaves.length === 1 && first !== void 0 ? first : {
16692
+ kind: "any",
16693
+ children: leaves
16694
+ };
16695
+ if (half.conditions === void 0) return triggers;
16696
+ return {
16697
+ kind: "all",
16698
+ children: [triggers, convertLegacyCondition(half.conditions)]
16699
+ };
16700
+ }
16701
+ /**
16702
+ * The automation RECIPE — what a visually-authored automation is, before it
16703
+ * becomes code.
16704
+ *
16705
+ * This is the data half of `docs/design/2026-08-08-automation-scripts-custom-devices.md`
16706
+ * Phase 6. A recipe is not an engine and never evaluates anything: it is a
16707
+ * declarative description that {@link generateAutomationBlock} compiles to the
16708
+ * TypeScript of an ordinary core block. There is therefore no second rule
16709
+ * engine, no second store and no second runtime — the block runner is the
16710
+ * runtime, `core_blocks` is the store (§2.1).
16711
+ *
16712
+ * Two parts:
16713
+ *
16714
+ * - **`when`** — ONE tree (`./when.ts`) of all/any/not over event leaves
16715
+ * (instants) and state leaves (durations). It replaces the flat trigger list
16716
+ * plus the separate condition tree; the firing rule is in that file and in
16717
+ * ADR "one tree".
16718
+ * - **Actions** — `NcRuleActionSchema`'s two steps, plus the `{kind:'code'}`
16719
+ * fragment of §3.2.2. See {@link AutomationActionSchema} for the ONE place
16720
+ * this had to diverge from "verbatim", and why.
16721
+ *
16722
+ * A recipe stored in the old shape (`triggers` + `conditions`) still LOADS:
16723
+ * {@link AutomationRecipeSchema} (the parse-anywhere schema) converts it on parse. Nothing writes it.
16724
+ */
16255
16725
  /**
16256
16726
  * One action step.
16257
16727
  *
@@ -16354,69 +16824,49 @@ var AutomationRunModeSchema = discriminatedUnion("mode", [
16354
16824
  minutes: number().int().min(1).max(1440)
16355
16825
  })
16356
16826
  ]);
16357
- var AutomationRecipeSchema = object({
16358
- triggers: array(AutomationTriggerSchema),
16359
- conditions: AutomationConditionSchema.optional(),
16827
+ var CurrentAutomationRecipeSchema = object({
16828
+ when: AutomationWhenSchema,
16360
16829
  actions: array(AutomationActionSchema),
16361
16830
  runMode: AutomationRunModeSchema.optional(),
16362
16831
  /** How many run traces the automation keeps (default {@link DEFAULT_TRACE_KEEP}). */
16363
16832
  keepTraces: number().int().min(1).max(100).optional()
16364
16833
  });
16365
- new Set([
16366
- {
16367
- id: "person",
16368
- name: "Person"
16369
- },
16370
- {
16371
- id: "vehicle",
16372
- name: "Vehicle"
16373
- },
16374
- {
16375
- id: "animal",
16376
- name: "Animal"
16377
- },
16378
- {
16379
- id: "package",
16380
- name: "Package"
16381
- }
16382
- ].map((l) => l.id));
16383
- var COCO_TO_MACRO = {
16384
- mapping: {
16385
- person: "person",
16386
- bicycle: "vehicle",
16387
- car: "vehicle",
16388
- motorcycle: "vehicle",
16389
- airplane: "vehicle",
16390
- bus: "vehicle",
16391
- train: "vehicle",
16392
- truck: "vehicle",
16393
- boat: "vehicle",
16394
- bird: "animal",
16395
- cat: "animal",
16396
- dog: "animal",
16397
- horse: "animal",
16398
- sheep: "animal",
16399
- cow: "animal",
16400
- elephant: "animal",
16401
- bear: "animal",
16402
- zebra: "animal",
16403
- giraffe: "animal",
16404
- suitcase: "package",
16405
- backpack: "package",
16406
- handbag: "package"
16407
- },
16408
- preserveOriginal: false
16409
- };
16834
+ /** A recipe as it was stored before the single tree. Read-only: nothing writes this. */
16835
+ var LegacyAutomationRecipeSchema = object({
16836
+ triggers: array(LegacyTriggerSchema),
16837
+ conditions: LegacyConditionSchema.optional(),
16838
+ actions: array(AutomationActionSchema),
16839
+ runMode: AutomationRunModeSchema.optional(),
16840
+ keepTraces: number().int().min(1).max(100).optional()
16841
+ });
16842
+ /** The old shape as the new one, lossless. */
16843
+ function convertLegacyRecipe(legacy) {
16844
+ const { triggers, conditions, ...rest } = legacy;
16845
+ return {
16846
+ when: convertLegacyWhen({
16847
+ triggers,
16848
+ conditions
16849
+ }),
16850
+ ...rest
16851
+ };
16852
+ }
16853
+ /**
16854
+ * Where recipes are PARSED (the core-blocks cap, the MCP tool, the literal a
16855
+ * saved block carries): the new shape as it is, or the old shape converted to
16856
+ * it. The output type is always the new one.
16857
+ */
16858
+ var AutomationRecipeSchema = union([CurrentAutomationRecipeSchema, LegacyAutomationRecipeSchema.transform(convertLegacyRecipe)]);
16410
16859
  new Set(Object.values(COCO_TO_MACRO.mapping));
16411
16860
  /**
16412
16861
  * Run traces — what an automation (or, later, an Action run) DID, node by node,
16413
16862
  * so the editor can paint every block green or red with its reason.
16414
16863
  *
16415
- * A trace is data, not a log: it covers the WHOLE tree — the trigger stage,
16416
- * every condition node (containers and leaves, including the ones a
16417
- * short-circuit never reached), the run-mode verdict and every action step —
16418
- * each addressed by a stable path (`conditions.0.1`, `actions.2`, or `steps.N`
16419
- * for an Action run). Skipped
16864
+ * A trace is data, not a log: it covers the WHOLE tree — the event leaf that
16865
+ * fired, every node of the `when` tree (containers and leaves, including the
16866
+ * ones a short-circuit never reached), the run-mode verdict and every action
16867
+ * step — each addressed by a stable path (`when.0.1`, `actions.2`, or `steps.N`
16868
+ * for an Action run). Traces written before the single tree carry
16869
+ * `conditions.*` paths and still parse. Skipped
16420
16870
  * evaluations are traced too, and identical consecutive skips collapse into a
16421
16871
  * repeat count so a trigger that fails every 5 minutes cannot flush real runs.
16422
16872
  *
@@ -16448,6 +16898,8 @@ var TraceNodeOutcomeSchema = _enum([
16448
16898
  ]);
16449
16899
  var AutomationTriggerTraceSchema = object({
16450
16900
  kind: string().min(1),
16901
+ /** The event leaf that fired (`when`, `when.0.1`); absent on traces written before the single tree. */
16902
+ path: string().min(1).optional(),
16451
16903
  outcome: _enum(["pass", "fail"]),
16452
16904
  /** Why an event was rejected ("class person not in [dog]") or what fired. */
16453
16905
  reason: string(),
@@ -16455,15 +16907,23 @@ var AutomationTriggerTraceSchema = object({
16455
16907
  vars: record(string(), TraceValueSchema)
16456
16908
  });
16457
16909
  var AutomationConditionTraceNodeSchema = object({
16458
- /** `conditions` for the root, `conditions.<i>` for its i-th child, … */
16910
+ /** `when` for the root, `when.<i>` for its i-th child, … (`conditions…` on an old trace). */
16459
16911
  path: string().min(1),
16460
16912
  kind: _enum([
16461
16913
  "all",
16462
16914
  "any",
16463
16915
  "not",
16464
16916
  "condition",
16917
+ "device-is",
16465
16918
  "expression",
16466
- "stationary-present"
16919
+ "stationary-present",
16920
+ "device-becomes",
16921
+ "device-event",
16922
+ "schedule",
16923
+ "interval",
16924
+ "manual",
16925
+ "detection",
16926
+ "stationary"
16467
16927
  ]),
16468
16928
  outcome: TraceNodeOutcomeSchema,
16469
16929
  reason: string(),
@@ -16497,6 +16957,12 @@ var AutomationStepTraceSchema = object({
16497
16957
  deviceId: number().int().optional(),
16498
16958
  startedAt: number().optional(),
16499
16959
  endedAt: number().optional(),
16960
+ /**
16961
+ * The parameters the step SENT, after `{{vars.x}}` substitution — what the
16962
+ * device was actually asked. Bounded like trigger `vars`; a secret parameter
16963
+ * is shown as {@link REDACTED_ARG}. Absent on traces written before it existed.
16964
+ */
16965
+ args: record(string(), TraceValueSchema).optional(),
16500
16966
  outcome: _enum([
16501
16967
  "ok",
16502
16968
  "error",
@@ -17246,6 +17712,23 @@ method(object({
17246
17712
  addonId: string(),
17247
17713
  nodeId: string().optional()
17248
17714
  }), SettingsSchemaWithValuesSchema.nullable()), method(object({
17715
+ addonId: string(),
17716
+ nodeId: string().optional()
17717
+ }), object({ keys: array(string()) }).nullable(), { auth: "admin" }), method(object({
17718
+ addonId: string(),
17719
+ nodeId: string().optional(),
17720
+ keys: array(string())
17721
+ }), object({
17722
+ removed: array(string()),
17723
+ refused: array(object({
17724
+ key: string(),
17725
+ reason: string()
17726
+ })),
17727
+ secretsCleared: number()
17728
+ }), {
17729
+ kind: "mutation",
17730
+ auth: "admin"
17731
+ }), method(object({
17249
17732
  addonId: string(),
17250
17733
  deviceId: number(),
17251
17734
  nodeId: string().optional()
@@ -21275,6 +21758,8 @@ var StorageKindSchema = object({
21275
21758
  "orphaned",
21276
21759
  "dropped"
21277
21760
  ]),
21761
+ /** An owner declared this kind since the engine started. */
21762
+ declaredThisBoot: boolean(),
21278
21763
  /** `null` when the caller asked for no counts (`counts: false`). */
21279
21764
  rows: number().int().nonnegative().nullable(),
21280
21765
  /** Rows x the average size of a sample; null for an empty kind. */
@@ -21286,6 +21771,51 @@ var StorageKindSchema = object({
21286
21771
  /** The whole declaration, secret paths and entry descriptor included (the entries service builds its handle from it). */
21287
21772
  declaration: RecordKindDeclarationSchema
21288
21773
  });
21774
+ _enum([
21775
+ "orphaned",
21776
+ "stale",
21777
+ "unknown"
21778
+ ]);
21779
+ /** A record kind nobody declared since its owner started, or whose owner is gone. */
21780
+ var StaleKindFindingSchema = object({
21781
+ owner: string(),
21782
+ kind: string(),
21783
+ /** `unknown`: the owner could not be judged (not running, or its state unreadable) - never dropped. */
21784
+ verdict: _enum(["orphaned", "unknown"]),
21785
+ reason: string(),
21786
+ rows: number().int().nonnegative().nullable(),
21787
+ /** local-auth's tables are never dropped by this audit. */
21788
+ protected: boolean()
21789
+ });
21790
+ /** A stored global-settings key the running addon no longer declares. */
21791
+ var StaleSettingsKeyFindingSchema = object({
21792
+ addonId: string(),
21793
+ /** The stored key as written, a per-node `<key>@<nodeId>` suffix included. */
21794
+ key: string(),
21795
+ /**
21796
+ * Keychain refs whose ONLY marker sits under this key: in use by stale data,
21797
+ * not orphans yet. Removing the key releases them.
21798
+ */
21799
+ secretRefs: array(string())
21800
+ });
21801
+ /** An addon the audit could not judge, with the reason. Never reported as stale. */
21802
+ var StaleUnknownSchema = object({
21803
+ subject: string(),
21804
+ reason: string()
21805
+ });
21806
+ var StaleDataReportSchema = object({
21807
+ /** Epoch ms of the last finished scan; `null` before the first. */
21808
+ scannedAt: number().nullable(),
21809
+ scanning: boolean(),
21810
+ trigger: _enum([
21811
+ "boot",
21812
+ "interval",
21813
+ "manual"
21814
+ ]).nullable(),
21815
+ kinds: array(StaleKindFindingSchema),
21816
+ settingsKeys: array(StaleSettingsKeyFindingSchema),
21817
+ unknown: array(StaleUnknownSchema)
21818
+ });
21289
21819
  method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: array(StorageTableSchema) }), { auth: "admin" }), method(object({
21290
21820
  owner: string().min(1).optional(),
21291
21821
  /** `false` skips the per-kind row count (a catalog read only). Default true. */
@@ -21293,7 +21823,30 @@ method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: arr
21293
21823
  }), object({ kinds: array(StorageKindSchema) }), { auth: "admin" }), method(RecordQueryInputSchema.extend({
21294
21824
  owner: string().min(1),
21295
21825
  kind: RecordKindNameSchema
21296
- }), RecordQueryResultSchema, { auth: "admin" });
21826
+ }), RecordQueryResultSchema, { auth: "admin" }), method(object({}), StaleDataReportSchema, { auth: "admin" }), method(object({}), StaleDataReportSchema, {
21827
+ kind: "mutation",
21828
+ auth: "admin"
21829
+ }), method(object({
21830
+ owner: string().min(1),
21831
+ kind: RecordKindNameSchema
21832
+ }), object({
21833
+ deleted: number().int().nonnegative(),
21834
+ secretsReleased: number().int().nonnegative()
21835
+ }), {
21836
+ kind: "mutation",
21837
+ auth: "admin",
21838
+ caller: "required"
21839
+ }), method(object({
21840
+ addonId: string().min(1),
21841
+ key: string().min(1)
21842
+ }), object({
21843
+ removed: boolean(),
21844
+ secretsCleared: number().int().nonnegative()
21845
+ }), {
21846
+ kind: "mutation",
21847
+ auth: "admin",
21848
+ caller: "required"
21849
+ });
21297
21850
  /**
21298
21851
  * record-entries - the ONE admin surface for every list of user-created entries
21299
21852
  * (D881): notification targets, brokers, LLM profiles, anything an addon
@@ -21432,6 +21985,12 @@ method(object({
21432
21985
  }), method(object({ deviceId: number().int() }), object({ refs: array(string()).readonly() }), {
21433
21986
  kind: "mutation",
21434
21987
  auth: "admin"
21988
+ }), method(object({
21989
+ refs: RefListSchema,
21990
+ onBehalfOf: string().min(1)
21991
+ }), object({ released: number().int() }), {
21992
+ kind: "mutation",
21993
+ auth: "admin"
21435
21994
  }), method(object({}), array(KeychainSecretMetaSchema).readonly(), { auth: "admin" }), method(object({}), KeychainStateSchema, { auth: "admin" });
21436
21995
  /**
21437
21996
  * device-history — the ONE mechanism for numeric history (D850).
@@ -22334,7 +22893,9 @@ var DiscoveredChildDeviceSchema = lazy(() => object({
22334
22893
  /** Upstream-system identity + rendering hints for the candidate. */
22335
22894
  sourceInfo: SourceInfoSchema.optional(),
22336
22895
  /** Nested entity-children for accordion preview — integrations populate, Hubs omit. */
22337
- children: array(DiscoveredChildDeviceSchema).readonly().optional()
22896
+ children: array(DiscoveredChildDeviceSchema).readonly().optional(),
22897
+ /** `childNativeId` of the gateway candidate in the same list this device sits behind. */
22898
+ parentStableId: string().optional()
22338
22899
  }));
22339
22900
  /**
22340
22901
  * Status block — populated by the integration after each
@@ -42717,6 +43278,79 @@ object({
42717
43278
  metadata: record(string(), unknown()).optional()
42718
43279
  });
42719
43280
  /**
43281
+ * HOW a provider can be found on the network. The provider only DECLARES the
43282
+ * mode; the `local-network` scan engine runs it once for every provider that
43283
+ * asked (one multicast browse, one host sweep) and hands the raw hits back to
43284
+ * `classifyDiscoveryHits`. A provider never opens a discovery socket itself,
43285
+ * except through a `custom` mode.
43286
+ */
43287
+ var DiscoveryModeSchema = discriminatedUnion("kind", [
43288
+ object({
43289
+ kind: literal("mdns"),
43290
+ /** DNS-SD service type, e.g. `_esphomelib._tcp`. */
43291
+ serviceType: string().min(1),
43292
+ /** Regular expression an instance name must match (case-insensitive). */
43293
+ instancePattern: string().optional()
43294
+ }),
43295
+ object({
43296
+ kind: literal("ws-discovery"),
43297
+ /** Probe types (e.g. `dn:NetworkVideoTransmitter`); omitted = any device. */
43298
+ types: array(string()).optional()
43299
+ }),
43300
+ object({
43301
+ kind: literal("ssdp"),
43302
+ searchTarget: string().min(1)
43303
+ }),
43304
+ object({
43305
+ kind: literal("udp-broadcast"),
43306
+ port: number().int().min(1).max(65535),
43307
+ payloadHex: string().regex(/^([0-9a-fA-F]{2})*$/),
43308
+ /** Port replies arrive on, when the device answers somewhere other than the source port. */
43309
+ listenPort: number().int().min(1).max(65535).optional()
43310
+ }),
43311
+ object({
43312
+ kind: literal("http-probe"),
43313
+ port: number().int().min(1).max(65535),
43314
+ path: string().startsWith("/"),
43315
+ method: _enum(["GET", "POST"]).default("GET"),
43316
+ bodyText: string().optional(),
43317
+ contentType: string().optional(),
43318
+ tls: boolean().default(false),
43319
+ /** Status codes that count as a hit. Omitted = any HTTP answer. */
43320
+ acceptStatus: array(number().int()).optional()
43321
+ }),
43322
+ object({
43323
+ kind: literal("custom"),
43324
+ /** Why the engine cannot do it (an allowlisted exception, e.g. a vendor UDP protocol). */
43325
+ reason: string().min(1)
43326
+ })
43327
+ ]);
43328
+ /** What the engine saw on the wire for ONE declared mode; the provider classifies it. */
43329
+ var DiscoveryHitSchema = object({
43330
+ /** Index into the provider's own `getDiscoveryModes()` array. */
43331
+ modeIndex: number().int().min(0),
43332
+ host: string(),
43333
+ port: number().int(),
43334
+ http: object({
43335
+ status: number().int(),
43336
+ headers: record(string(), string()),
43337
+ /** At most 16 KiB. */
43338
+ bodyText: string()
43339
+ }).optional(),
43340
+ mdns: object({
43341
+ instance: string(),
43342
+ host: string(),
43343
+ port: number().int(),
43344
+ txt: record(string(), string())
43345
+ }).optional(),
43346
+ udp: object({ payloadHex: string() }).optional(),
43347
+ wsd: object({
43348
+ xaddrs: array(string()),
43349
+ types: array(string()),
43350
+ scopes: array(string())
43351
+ }).optional()
43352
+ });
43353
+ /**
42720
43354
  * Candidate handed back from discovery and accepted by
42721
43355
  * `adoptDiscoveredDevice`. Shape mirrors the in-process
42722
43356
  * `DiscoveredDevice` interface used by `DeviceDiscovery`.
@@ -42751,7 +43385,17 @@ var DiscoveryCandidateSchema = object({
42751
43385
  /** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
42752
43386
  onboardedDeviceId: number().optional(),
42753
43387
  /** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
42754
- onboardedName: string().optional()
43388
+ onboardedName: string().optional(),
43389
+ /**
43390
+ * This candidate is an INTEGRATION to set up (a gateway, a hub, a controller),
43391
+ * not a device to adopt. Replaces the `prefilledConfig.setupIntegration` marker,
43392
+ * which is still read for one release.
43393
+ */
43394
+ setupIntegration: boolean().optional(),
43395
+ /** `stableId` of the gateway candidate this device was found BEHIND. */
43396
+ parentStableId: string().optional(),
43397
+ /** `false` = shown for context but not pickable. Default true. */
43398
+ selectable: boolean().optional()
42755
43399
  });
42756
43400
  /**
42757
43401
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -42848,6 +43492,24 @@ var deviceProviderCapability = {
42848
43492
  auth: "admin"
42849
43493
  }),
42850
43494
  /**
43495
+ * How this provider is found on the network. `[]` = the provider has no
43496
+ * declarative mode and keeps answering `discoverDevices` itself.
43497
+ */
43498
+ getDiscoveryModes: method(object({}), array(DiscoveryModeSchema)),
43499
+ /**
43500
+ * Turn the engine's raw hits into candidates. The provider may make
43501
+ * follow-up UNICAST requests to a hit host (an authenticated call, an
43502
+ * identity check) and never sweeps a subnet. `params` is the per-scan form
43503
+ * from `getDiscoveryParamsSchema`.
43504
+ */
43505
+ classifyDiscoveryHits: method(object({
43506
+ hits: array(DiscoveryHitSchema),
43507
+ params: record(string(), unknown()).optional()
43508
+ }), array(DiscoveryCandidateSchema), {
43509
+ kind: "mutation",
43510
+ auth: "admin"
43511
+ }),
43512
+ /**
42851
43513
  * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
42852
43514
  * provider accepts (e.g. Gree's broadcast address for a different subnet).
42853
43515
  * `null` when the provider takes no extra scan params — the generic
@@ -43612,17 +44274,23 @@ method(object({
43612
44274
  }), method(object({}), object({ providers: array(object({
43613
44275
  addonId: string(),
43614
44276
  label: string()
43615
- })).readonly() }), { auth: "admin" }), method(object({}), object({ groups: array(object({
43616
- addonId: string(),
43617
- label: string(),
43618
- candidates: array(DiscoveryCandidateSchema).readonly(),
43619
- error: string().nullable()
43620
- })).readonly() }), {
43621
- kind: "mutation",
43622
- auth: "admin"
43623
- }), method(object({
44277
+ })).readonly() }), { auth: "admin" }), method(
44278
+ /** `subnets`: extra CIDRs for THIS scan, on top of the hub's own and the saved list. */
44279
+ object({ subnets: array(string()).optional() }),
44280
+ object({ groups: array(object({
44281
+ addonId: string(),
44282
+ label: string(),
44283
+ candidates: array(DiscoveryCandidateSchema).readonly(),
44284
+ error: string().nullable()
44285
+ })).readonly() }),
44286
+ {
44287
+ kind: "mutation",
44288
+ auth: "admin"
44289
+ }
44290
+ ), method(object({
43624
44291
  addonId: string(),
43625
- params: record(string(), unknown()).optional()
44292
+ params: record(string(), unknown()).optional(),
44293
+ subnets: array(string()).optional()
43626
44294
  }), object({ candidates: array(DiscoveryCandidateSchema).readonly() }), {
43627
44295
  kind: "mutation",
43628
44296
  auth: "admin"
@@ -62964,7 +63632,9 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
62964
63632
  id: string(),
62965
63633
  settings: record(string(), unknown()),
62966
63634
  /** Keys the form's schema declares secret (by type) — keychain-stored (D787). */
62967
- secretKeys: array(string()).optional()
63635
+ secretKeys: array(string()).optional(),
63636
+ /** The integration's own addon applies the change in-process and asks the hub not to respawn it. */
63637
+ skipRestart: boolean().optional()
62968
63638
  }), object({ success: boolean() }), {
62969
63639
  kind: "mutation",
62970
63640
  auth: "admin"
@@ -63592,6 +64262,39 @@ var TlsStatusSchema = object({
63592
64262
  hasCa: boolean()
63593
64263
  }).nullable()
63594
64264
  });
64265
+ /** One provider's ask of the scan engine: the modes it declared. */
64266
+ var ScanRequestSchema = object({
64267
+ providerId: string().min(1),
64268
+ modes: array(DiscoveryModeSchema)
64269
+ });
64270
+ var ScanErrorSchema = object({
64271
+ /** Absent for a failure that is the scan's own (a bad subnet, a socket that would not open). */
64272
+ providerId: string().optional(),
64273
+ /** The mode family that failed (`mdns`, `http-probe`, …). */
64274
+ mode: string().optional(),
64275
+ message: string()
64276
+ });
64277
+ /** What one scan did, per mode family; the engine logs the same numbers once. */
64278
+ var ScanStatsSchema = object({
64279
+ hostsProbed: number().int(),
64280
+ hits: number().int(),
64281
+ timeouts: number().int(),
64282
+ byMode: record(string(), object({
64283
+ probed: number().int(),
64284
+ hits: number().int(),
64285
+ timeouts: number().int()
64286
+ }))
64287
+ });
64288
+ var ScanNetworkResultSchema = object({
64289
+ hits: array(object({
64290
+ providerId: string(),
64291
+ hit: DiscoveryHitSchema
64292
+ })),
64293
+ /** CIDRs actually covered: the hub's own /24s, the saved list and the per-call list. */
64294
+ subnetsScanned: array(string()),
64295
+ errors: array(ScanErrorSchema),
64296
+ stats: ScanStatsSchema
64297
+ });
63595
64298
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
63596
64299
  /**
63597
64300
  * LEGACY HINT — do not send from new code. Kept optional so clients
@@ -63646,6 +64349,16 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
63646
64349
  }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
63647
64350
  kind: "mutation",
63648
64351
  auth: "admin"
64352
+ }), method(object({
64353
+ requests: array(ScanRequestSchema),
64354
+ subnets: array(string()).optional()
64355
+ }), ScanNetworkResultSchema, {
64356
+ kind: "mutation",
64357
+ auth: "admin",
64358
+ timeoutMs: 9e4
64359
+ }), method(_void(), object({ subnets: array(string()) }), { auth: "admin" }), method(object({ subnets: array(string()) }), object({ subnets: array(string()) }), {
64360
+ kind: "mutation",
64361
+ auth: "admin"
63649
64362
  }), method(_void(), TlsStatusSchema, {
63650
64363
  kind: "mutation",
63651
64364
  auth: "admin"
@@ -73254,6 +73967,17 @@ var BaseDeviceProvider = class extends BaseAddon {
73254
73967
  async discoverDevices(_input) {
73255
73968
  return [];
73256
73969
  }
73970
+ /**
73971
+ * How the scan engine finds this provider. Default `[]`: the provider is
73972
+ * not migrated and keeps answering `discoverDevices` itself.
73973
+ */
73974
+ async getDiscoveryModes() {
73975
+ return [];
73976
+ }
73977
+ /** Turn the scan engine's raw hits into candidates. Default: none. */
73978
+ async classifyDiscoveryHits(_input) {
73979
+ return [];
73980
+ }
73257
73981
  /** Extra per-scan input form (e.g. a broadcast address for another subnet).
73258
73982
  * Null = no extra params. Override in providers that support scoped scans. */
73259
73983
  async getDiscoveryParamsSchema() {
@@ -74122,6 +74846,13 @@ Object.freeze({
74122
74846
  access: "create",
74123
74847
  role: "admin"
74124
74848
  },
74849
+ "addonSettings.getDeclaredSettingsKeys": {
74850
+ capName: "addon-settings",
74851
+ capScope: "system",
74852
+ addonId: null,
74853
+ access: "view",
74854
+ role: "admin"
74855
+ },
74125
74856
  "addonSettings.getDeviceSettings": {
74126
74857
  capName: "addon-settings",
74127
74858
  capScope: "system",
@@ -74143,6 +74874,13 @@ Object.freeze({
74143
74874
  access: "view",
74144
74875
  role: "admin"
74145
74876
  },
74877
+ "addonSettings.unsetStoredSettingsKeys": {
74878
+ capName: "addon-settings",
74879
+ capScope: "system",
74880
+ addonId: null,
74881
+ access: "create",
74882
+ role: "admin"
74883
+ },
74146
74884
  "addonSettings.updateDeviceSettings": {
74147
74885
  capName: "addon-settings",
74148
74886
  capScope: "system",
@@ -76558,6 +77296,13 @@ Object.freeze({
76558
77296
  access: "create",
76559
77297
  role: "admin"
76560
77298
  },
77299
+ "deviceProvider.classifyDiscoveryHits": {
77300
+ capName: "device-provider",
77301
+ capScope: "system",
77302
+ addonId: null,
77303
+ access: "create",
77304
+ role: "admin"
77305
+ },
76561
77306
  "deviceProvider.createDevice": {
76562
77307
  capName: "device-provider",
76563
77308
  capScope: "system",
@@ -76586,6 +77331,13 @@ Object.freeze({
76586
77331
  access: "view",
76587
77332
  role: "admin"
76588
77333
  },
77334
+ "deviceProvider.getDiscoveryModes": {
77335
+ capName: "device-provider",
77336
+ capScope: "system",
77337
+ addonId: null,
77338
+ access: "view",
77339
+ role: "admin"
77340
+ },
76589
77341
  "deviceProvider.getDiscoveryParamsSchema": {
76590
77342
  capName: "device-provider",
76591
77343
  capScope: "system",
@@ -77363,6 +78115,13 @@ Object.freeze({
77363
78115
  access: "create",
77364
78116
  role: "admin"
77365
78117
  },
78118
+ "keychain.releaseRecordRefs": {
78119
+ capName: "keychain",
78120
+ capScope: "system",
78121
+ addonId: null,
78122
+ access: "create",
78123
+ role: "admin"
78124
+ },
77366
78125
  "keychain.resolve": {
77367
78126
  capName: "keychain",
77368
78127
  capScope: "system",
@@ -77783,6 +78542,13 @@ Object.freeze({
77783
78542
  access: "view",
77784
78543
  role: "admin"
77785
78544
  },
78545
+ "localNetwork.getScanSubnets": {
78546
+ capName: "local-network",
78547
+ capScope: "system",
78548
+ addonId: null,
78549
+ access: "view",
78550
+ role: "admin"
78551
+ },
77786
78552
  "localNetwork.getTlsStatus": {
77787
78553
  capName: "local-network",
77788
78554
  capScope: "system",
@@ -77832,6 +78598,13 @@ Object.freeze({
77832
78598
  access: "create",
77833
78599
  role: "admin"
77834
78600
  },
78601
+ "localNetwork.scanNetwork": {
78602
+ capName: "local-network",
78603
+ capScope: "system",
78604
+ addonId: null,
78605
+ access: "create",
78606
+ role: "admin"
78607
+ },
77835
78608
  "localNetwork.setAddressRole": {
77836
78609
  capName: "local-network",
77837
78610
  capScope: "system",
@@ -77853,6 +78626,13 @@ Object.freeze({
77853
78626
  access: "create",
77854
78627
  role: "admin"
77855
78628
  },
78629
+ "localNetwork.setScanSubnets": {
78630
+ capName: "local-network",
78631
+ capScope: "system",
78632
+ addonId: null,
78633
+ access: "create",
78634
+ role: "admin"
78635
+ },
77856
78636
  "localNetwork.setViewerEndpoints": {
77857
78637
  capName: "local-network",
77858
78638
  capScope: "system",
@@ -82074,6 +82854,13 @@ Object.freeze({
82074
82854
  access: "create",
82075
82855
  role: "admin"
82076
82856
  },
82857
+ "storageAdmin.dropOrphanedKind": {
82858
+ capName: "storage-admin",
82859
+ capScope: "system",
82860
+ addonId: null,
82861
+ access: "delete",
82862
+ role: "admin"
82863
+ },
82077
82864
  "storageAdmin.listKinds": {
82078
82865
  capName: "storage-admin",
82079
82866
  capScope: "system",
@@ -82095,6 +82882,27 @@ Object.freeze({
82095
82882
  access: "view",
82096
82883
  role: "admin"
82097
82884
  },
82885
+ "storageAdmin.scanStaleData": {
82886
+ capName: "storage-admin",
82887
+ capScope: "system",
82888
+ addonId: null,
82889
+ access: "create",
82890
+ role: "admin"
82891
+ },
82892
+ "storageAdmin.staleDataReport": {
82893
+ capName: "storage-admin",
82894
+ capScope: "system",
82895
+ addonId: null,
82896
+ access: "view",
82897
+ role: "admin"
82898
+ },
82899
+ "storageAdmin.unsetStaleSettingsKey": {
82900
+ capName: "storage-admin",
82901
+ capScope: "system",
82902
+ addonId: null,
82903
+ access: "create",
82904
+ role: "admin"
82905
+ },
82098
82906
  "storageEvictable.evict": {
82099
82907
  capName: "storage-evictable",
82100
82908
  capScope: "system",
@@ -100917,79 +101725,18 @@ function setIntegrationDevicesOnline(devices, integrationId, online) {
100917
101725
  }
100918
101726
  //#endregion
100919
101727
  //#region src/velux-gateway-discovery.ts
100920
- /** `prefilledConfig` flag: the scan opens the integration form instead of adopting a device. */
100921
- var VELUX_SETUP_INTEGRATION_KEY = "setupIntegration";
100922
- /** Top bit of the question class: ask for a unicast reply (RFC 6762). */
100923
- var MDNS_QU_CLASS = 32769;
100924
101728
  /** The KLF-200 announces itself as an `_http._tcp` service named `VELUX_KLF_<id>`. */
100925
- var VELUX_MDNS_SERVICE = "_http._tcp.local";
101729
+ var VELUX_MDNS_SERVICE = "_http._tcp";
100926
101730
  var VELUX_GATEWAY_NAME_PREFIX = "VELUX_KLF";
100927
- var VELUX_DISCOVERY_TIMEOUT_MS = 1500;
100928
- var MDNS_PORT = 5353;
100929
- var MDNS_ADDRESS = "224.0.0.251";
100930
- var PTR = 12;
100931
- var A = 1;
100932
- var SRV = 33;
101731
+ /** How the scan engine finds a KLF-200: one mDNS browse, filtered on the instance name. */
101732
+ var VELUX_DISCOVERY_MODES = [{
101733
+ kind: "mdns",
101734
+ serviceType: VELUX_MDNS_SERVICE,
101735
+ instancePattern: `^${VELUX_GATEWAY_NAME_PREFIX}`
101736
+ }];
100933
101737
  function isVeluxGatewayName(name) {
100934
101738
  return (name.split("._")[0] ?? name).toLowerCase().startsWith(VELUX_GATEWAY_NAME_PREFIX.toLowerCase());
100935
101739
  }
100936
- function readName(buf, start) {
100937
- const labels = [];
100938
- let pos = start;
100939
- let end = start;
100940
- let jumped = false;
100941
- for (let hops = 0; hops < 16; hops++) {
100942
- if (pos < 0 || pos >= buf.length) return null;
100943
- const len = buf[pos] ?? 0;
100944
- if (len === 0) {
100945
- if (!jumped) end = pos + 1;
100946
- return {
100947
- name: labels.join("."),
100948
- end
100949
- };
100950
- }
100951
- if ((len & 192) === 192) {
100952
- if (pos + 1 >= buf.length) return null;
100953
- if (!jumped) end = pos + 2;
100954
- jumped = true;
100955
- pos = (len & 63) << 8 | (buf[pos + 1] ?? 0);
100956
- continue;
100957
- }
100958
- if ((len & 192) !== 0 || pos + 1 + len > buf.length) return null;
100959
- labels.push(buf.subarray(pos + 1, pos + 1 + len).toString("utf8"));
100960
- pos += 1 + len;
100961
- if (!jumped) end = pos;
100962
- }
100963
- return null;
100964
- }
100965
- function readRecords(buf, offset, count) {
100966
- const records = [];
100967
- let pos = offset;
100968
- for (let i = 0; i < count; i++) {
100969
- const name = readName(buf, pos);
100970
- if (name === null || name.end + 10 > buf.length) return null;
100971
- const type = buf.readUInt16BE(name.end);
100972
- const dataLength = buf.readUInt16BE(name.end + 8);
100973
- const dataOffset = name.end + 10;
100974
- if (dataOffset + dataLength > buf.length) return null;
100975
- records.push({
100976
- name: name.name,
100977
- type,
100978
- message: buf,
100979
- dataOffset,
100980
- dataLength
100981
- });
100982
- pos = dataOffset + dataLength;
100983
- }
100984
- return {
100985
- records,
100986
- next: pos
100987
- };
100988
- }
100989
- function ipv4(buf, offset, length) {
100990
- if (length !== 4 || offset + 4 > buf.length) return null;
100991
- return `${buf[offset] ?? 0}.${buf[offset + 1] ?? 0}.${buf[offset + 2] ?? 0}.${buf[offset + 3] ?? 0}`;
100992
- }
100993
101740
  /** Identity used to recognise a gateway already stored on an integration. */
100994
101741
  function veluxGatewayId(input) {
100995
101742
  const service = input.serviceHost?.trim().toLowerCase() ?? "";
@@ -100998,40 +101745,24 @@ function veluxGatewayId(input) {
100998
101745
  if (serial.length > 0) return serial;
100999
101746
  return input.host;
101000
101747
  }
101001
- function gatewaysFromRecords(records) {
101002
- const instances = [];
101003
- const srv = /* @__PURE__ */ new Map();
101004
- const addresses = /* @__PURE__ */ new Map();
101005
- for (const rec of records) {
101006
- const key = rec.name.toLowerCase();
101007
- if (rec.type === PTR) {
101008
- const target = readName(rec.message, rec.dataOffset);
101009
- if (target !== null && isVeluxGatewayName(target.name)) instances.push(target.name);
101010
- } else if (rec.type === SRV && rec.dataLength >= 6) {
101011
- const target = readName(rec.message, rec.dataOffset + 6);
101012
- if (target !== null) srv.set(key, target.name);
101013
- } else if (rec.type === A) {
101014
- const ip = ipv4(rec.message, rec.dataOffset, rec.dataLength);
101015
- if (ip !== null) addresses.set(key, ip);
101016
- }
101017
- }
101748
+ /** Gateways among the engine's mDNS hits (a `VELUX_KLF…` instance), one per host. */
101749
+ function veluxGatewaysFromHits(hits) {
101018
101750
  const out = [];
101019
101751
  const seen = /* @__PURE__ */ new Set();
101020
- for (const instance of instances) {
101021
- const viaSrv = srv.get(instance.toLowerCase());
101022
- const host = (viaSrv !== void 0 ? addresses.get(viaSrv.toLowerCase()) : void 0) ?? addresses.get(instance.toLowerCase());
101023
- if (host === void 0 || seen.has(host)) continue;
101024
- seen.add(host);
101025
- const label = instance.split("._")[0] ?? instance;
101026
- const id = veluxGatewayId({
101027
- name: label,
101028
- host,
101029
- ...viaSrv !== void 0 ? { serviceHost: viaSrv } : {}
101030
- });
101752
+ for (const hit of hits) {
101753
+ const mdns = hit.mdns;
101754
+ if (mdns === void 0 || !isVeluxGatewayName(mdns.instance)) continue;
101755
+ if (hit.host.length === 0 || seen.has(hit.host)) continue;
101756
+ seen.add(hit.host);
101757
+ const name = mdns.instance.split("._")[0] ?? mdns.instance;
101031
101758
  out.push({
101032
- host,
101033
- name: label,
101034
- id
101759
+ host: hit.host,
101760
+ name,
101761
+ id: veluxGatewayId({
101762
+ name,
101763
+ host: hit.host,
101764
+ serviceHost: mdns.host
101765
+ })
101035
101766
  });
101036
101767
  }
101037
101768
  return out;
@@ -101051,11 +101782,10 @@ function proposeVeluxGateways(found, existingHosts) {
101051
101782
  function veluxDiscoveryCandidates(proposals) {
101052
101783
  return proposals.map((found) => ({
101053
101784
  stableId: `velux:${found.id}`,
101785
+ type: DeviceType.Gateway,
101054
101786
  suggestedName: found.name,
101055
- prefilledConfig: {
101056
- host: found.host,
101057
- [VELUX_SETUP_INTEGRATION_KEY]: true
101058
- }
101787
+ prefilledConfig: { host: found.host },
101788
+ setupIntegration: true
101059
101789
  }));
101060
101790
  }
101061
101791
  function veluxDiscoverySummary(foundCount, proposedCount) {
@@ -101063,101 +101793,6 @@ function veluxDiscoverySummary(foundCount, proposedCount) {
101063
101793
  if (proposedCount === 0) return "No gateway found on the network.";
101064
101794
  return `Found ${proposedCount} ${proposedCount === 1 ? "gateway" : "gateways"}.`;
101065
101795
  }
101066
- /** Gateways advertised as a KLF-200 (`VELUX_KLF…` instance) on one mDNS message. */
101067
- function veluxGatewaysFromDnsMessage(buf) {
101068
- if (buf.length < 12) return [];
101069
- const questions = buf.readUInt16BE(4);
101070
- const answers = buf.readUInt16BE(6);
101071
- const authorities = buf.readUInt16BE(8);
101072
- const extra = buf.readUInt16BE(10);
101073
- let pos = 12;
101074
- for (let i = 0; i < questions; i++) {
101075
- const name = readName(buf, pos);
101076
- if (name === null) return [];
101077
- pos = name.end + 4;
101078
- if (pos > buf.length) return [];
101079
- }
101080
- const answerRecords = readRecords(buf, pos, answers);
101081
- if (answerRecords === null) return [];
101082
- const authorityRecords = readRecords(buf, answerRecords.next, authorities);
101083
- if (authorityRecords === null) return [];
101084
- const extraRecords = readRecords(buf, authorityRecords.next, extra);
101085
- if (extraRecords === null) return [];
101086
- return gatewaysFromRecords([
101087
- ...answerRecords.records,
101088
- ...authorityRecords.records,
101089
- ...extraRecords.records
101090
- ]);
101091
- }
101092
- function mdnsPtrQuery(service) {
101093
- const encoded = service.split(".").filter((part) => part.length > 0).map((label) => Buffer.concat([Buffer.from([label.length]), Buffer.from(label)]));
101094
- const header = Buffer.alloc(12);
101095
- header.writeUInt16BE(1, 4);
101096
- const tail = Buffer.alloc(4);
101097
- tail.writeUInt16BE(PTR, 0);
101098
- tail.writeUInt16BE(MDNS_QU_CLASS, 2);
101099
- return Buffer.concat([
101100
- header,
101101
- ...encoded,
101102
- Buffer.from([0]),
101103
- tail
101104
- ]);
101105
- }
101106
- function closeQuietly(socket) {
101107
- try {
101108
- socket.close();
101109
- } catch {}
101110
- }
101111
- function exchangeMdns(query, timeoutMs) {
101112
- return new Promise((resolve) => {
101113
- let socket;
101114
- try {
101115
- socket = (0, node_dgram.createSocket)({
101116
- type: "udp4",
101117
- reuseAddr: true
101118
- });
101119
- } catch {
101120
- resolve([]);
101121
- return;
101122
- }
101123
- const packets = [];
101124
- let settled = false;
101125
- const finish = () => {
101126
- if (settled) return;
101127
- settled = true;
101128
- closeQuietly(socket);
101129
- resolve(packets);
101130
- };
101131
- socket.on("error", () => finish());
101132
- socket.on("message", (msg) => {
101133
- packets.push(Buffer.from(msg));
101134
- });
101135
- socket.bind(0, () => {
101136
- try {
101137
- socket.send(query, MDNS_PORT, MDNS_ADDRESS);
101138
- } catch {
101139
- finish();
101140
- }
101141
- });
101142
- setTimeout(finish, timeoutMs);
101143
- });
101144
- }
101145
- async function browseVeluxGateways(exchange = exchangeMdns) {
101146
- let packets;
101147
- try {
101148
- packets = await exchange(mdnsPtrQuery(VELUX_MDNS_SERVICE), VELUX_DISCOVERY_TIMEOUT_MS);
101149
- } catch {
101150
- return [];
101151
- }
101152
- const hosts = [];
101153
- const seen = /* @__PURE__ */ new Set();
101154
- for (const packet of packets) for (const found of veluxGatewaysFromDnsMessage(packet)) {
101155
- if (seen.has(found.host)) continue;
101156
- seen.add(found.host);
101157
- hosts.push(found);
101158
- }
101159
- return hosts;
101160
- }
101161
101796
  //#endregion
101162
101797
  //#region src/addon.ts
101163
101798
  /**
@@ -101212,14 +101847,15 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
101212
101847
  ],
101213
101848
  customActions: veluxActions,
101214
101849
  actionHandlers: { discoverGateway: async () => {
101215
- const { proposals, summary } = await this.discoverGateways();
101850
+ const { candidates } = await this.ctx.api.deviceManager.discoverProvider.mutate({ addonId: this.ctx.id });
101851
+ const hosts = candidates.map((candidate) => ({
101852
+ host: String(candidate.prefilledConfig["host"] ?? ""),
101853
+ name: candidate.suggestedName,
101854
+ id: candidate.stableId.replace(/^velux:/, "")
101855
+ }));
101216
101856
  return {
101217
- hosts: proposals.map((found) => ({
101218
- host: found.host,
101219
- name: found.name,
101220
- id: found.id
101221
- })),
101222
- summary
101857
+ hosts,
101858
+ summary: veluxDiscoverySummary(hosts.length, hosts.length)
101223
101859
  };
101224
101860
  } }
101225
101861
  };
@@ -101240,21 +101876,21 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
101240
101876
  async supportsManualCreation() {
101241
101877
  return false;
101242
101878
  }
101243
- /**
101244
- * LAN mDNS probe for a KLF-200. This addon is hub-only, so the socket is the
101245
- * hub's. The probe asks for a unicast reply and does not bind 5353.
101246
- */
101879
+ /** A KLF-200 is found by the scan engine's mDNS browse; this addon only classifies the hits. */
101247
101880
  async supportsDiscovery() {
101248
101881
  return true;
101249
101882
  }
101250
- async discoverDevices() {
101251
- const { proposals } = await this.discoverGateways();
101252
- return veluxDiscoveryCandidates(proposals).map((candidate) => ({
101253
- stableId: candidate.stableId,
101254
- type: DeviceType.Gateway,
101255
- suggestedName: candidate.suggestedName,
101256
- prefilledConfig: { ...candidate.prefilledConfig }
101257
- }));
101883
+ async getDiscoveryModes() {
101884
+ return VELUX_DISCOVERY_MODES;
101885
+ }
101886
+ async classifyDiscoveryHits(input) {
101887
+ const found = veluxGatewaysFromHits(input.hits);
101888
+ const proposals = proposeVeluxGateways(found, await this.knownGatewayHosts());
101889
+ if (found.length > proposals.length) this.ctx.logger.info("Velux discovery skipped gateways already added", { meta: {
101890
+ found: found.length,
101891
+ proposed: proposals.length
101892
+ } });
101893
+ return veluxDiscoveryCandidates(proposals);
101258
101894
  }
101259
101895
  async adoptDiscoveredDevice(_input) {
101260
101896
  throw new Error("A Velux gateway is added from the integration form");
@@ -101271,23 +101907,6 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
101271
101907
  if (typeof nativeId !== "string" || nativeId.length === 0) throw new Error("provider-velux generateStableId: nativeId required");
101272
101908
  return `velux:${nativeId}`;
101273
101909
  }
101274
- async discoverGateways() {
101275
- let found = [];
101276
- try {
101277
- found = await browseVeluxGateways();
101278
- } catch (err) {
101279
- this.ctx.logger.warn("Velux gateway discovery failed", { meta: { error: errMsg(err) } });
101280
- }
101281
- const proposals = proposeVeluxGateways(found, await this.knownGatewayHosts());
101282
- if (found.length > proposals.length) this.ctx.logger.info("Velux discovery skipped gateways already added", { meta: {
101283
- found: found.length,
101284
- proposed: proposals.length
101285
- } });
101286
- return {
101287
- proposals,
101288
- summary: veluxDiscoverySummary(found.length, proposals.length)
101289
- };
101290
- }
101291
101910
  async knownGatewayHosts() {
101292
101911
  try {
101293
101912
  const all = await this.ctx.api.integrations.list.query();