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