@camstack/addon-provider-velux 0.2.152 → 0.2.154

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.154] - 2026-11-01
11
+
12
+ ### Changed
13
+
14
+ - The gateway connection test now names the step that timed out (TCP, secure login, version, close) within 17 s and frees the session, instead of hanging until the wizard gives up; an empty gateway scan is logged.
15
+
16
+ ## [0.2.153] - 2026-11-01
17
+
18
+ ### Changed
19
+
20
+ - An integration deleted while its provider was down now has its orphan devices removed after two concordant reads of the integration list
21
+
10
22
  ## [0.2.152] - 2026-11-01
11
23
 
12
24
  ### Changed
package/dist/addon.js CHANGED
@@ -33,6 +33,7 @@ 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_net = require("node:net");
36
37
  //#region ../types/dist/event-category-CaURIwQA.mjs
37
38
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
38
39
  EventCategory["SystemBoot"] = "system.boot";
@@ -66414,6 +66415,60 @@ onColorChanged: { data: object({
66414
66415
  */
66415
66416
  history: "none"
66416
66417
  };
66418
+ /**
66419
+ * `connection-test` — pre-creation validation of an integration's settings,
66420
+ * system-scoped collection (one provider per integration addon).
66421
+ *
66422
+ * ── Why this cap exists ─────────────────────────────────────────────────────
66423
+ *
66424
+ * Before it, `integrations.testConnection` had exactly two behaviours: a broker
66425
+ * branch (`settings.brokerId` → `broker.testConnection`) and a DEFAULT branch
66426
+ * that probed `settings.main_stream_url ?? settings.url` with ffprobe. Every
66427
+ * account-based integration — Dreo, Dreame, Tuya, Petkit, Wyze — fell into that
66428
+ * default and was told `{"success":false,"error":"No stream URL provided"}`.
66429
+ * That answer neither validated nor refused anything, so:
66430
+ *
66431
+ * - the Test button had NEVER worked for an account integration, and
66432
+ * - `integrations.create` had nothing to gate on, so an integration with a
66433
+ * wrong password was created happily and failed later, silently, at
66434
+ * reconcile time.
66435
+ *
66436
+ * The structural gap was that at CREATE time the integration does not exist
66437
+ * yet: there is no `integrationId`, no `brokerId`, no live client — and every
66438
+ * existing provider surface (`device-provider`, `device-adoption`) is keyed by
66439
+ * one of those. There was no way to ask "are these settings valid?" before the
66440
+ * row existed.
66441
+ *
66442
+ * ── The contract ────────────────────────────────────────────────────────────
66443
+ *
66444
+ * The provider knows how to validate its OWN settings. The framework does not
66445
+ * guess from the shape of the settings — a stream-URL probe is one provider's
66446
+ * implementation of this cap, not the universal fallback. A provider that does
66447
+ * not register this cap is a KNOWN "cannot validate", never a silent pass.
66448
+ *
66449
+ * `testSettings` takes the candidate settings blob exactly as the wizard's
66450
+ * config step collected it (the same blob that would be handed to
66451
+ * `integrations.create`), and MUST NOT persist anything, mint an integration,
66452
+ * or mutate live state. It opens a throwaway session, asks, and closes it.
66453
+ *
66454
+ * ── The three outcomes are NOT interchangeable ──────────────────────────────
66455
+ *
66456
+ * This is the whole point of the discriminated union: a `null` from a timeout
66457
+ * must never look like a `null` from a refusal.
66458
+ *
66459
+ * - `validated` — the remote ACCEPTED these credentials. Observed.
66460
+ * - `rejected` — the remote REFUSED these credentials. Observed.
66461
+ * This is the ONLY outcome that blocks creation.
66462
+ * - `inconclusive` — the check could not complete (DNS, timeout, 5xx, an
66463
+ * unexpected shape). NOTHING was observed about the
66464
+ * credentials. Never rendered, or counted, as a failure.
66465
+ *
66466
+ * A provider that cannot tell a refusal from a transport fault must return
66467
+ * `inconclusive`. Guessing `rejected` would block creation on a flaky network;
66468
+ * guessing `validated` would wave a wrong password through.
66469
+ */
66470
+ /** Ceiling on how long a pre-creation probe may hold the wizard. */
66471
+ var CONNECTION_TEST_TIMEOUT_MS = 2e4;
66417
66472
  var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
66418
66473
  object({
66419
66474
  outcome: literal("validated"),
@@ -94566,6 +94621,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94566
94621
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94567
94622
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94568
94623
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94624
+ /**
94625
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94626
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94627
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94628
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94629
+ */
94630
+ var OrphanIntegrationSweep = class {
94631
+ #logger;
94632
+ #minIntervalMs;
94633
+ #now;
94634
+ #suspects = /* @__PURE__ */ new Map();
94635
+ constructor(options) {
94636
+ this.#logger = options.logger;
94637
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94638
+ this.#now = options.now ?? Date.now;
94639
+ }
94640
+ get minIntervalMs() {
94641
+ return this.#minIntervalMs;
94642
+ }
94643
+ async sweep(input) {
94644
+ if (input.listedIds === null) return {
94645
+ newSuspects: 0,
94646
+ removed: []
94647
+ };
94648
+ const listed = new Set(input.listedIds);
94649
+ const owners = new Set(input.ownerIds);
94650
+ const now = this.#now();
94651
+ let newSuspects = 0;
94652
+ const removed = [];
94653
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94654
+ this.#suspects.delete(id);
94655
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94656
+ integrationId: id,
94657
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94658
+ } });
94659
+ }
94660
+ for (const id of owners) {
94661
+ if (listed.has(id)) continue;
94662
+ const since = this.#suspects.get(id);
94663
+ if (since === void 0) {
94664
+ this.#suspects.set(id, now);
94665
+ newSuspects += 1;
94666
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94667
+ integrationId: id,
94668
+ minIntervalMs: this.#minIntervalMs
94669
+ } });
94670
+ continue;
94671
+ }
94672
+ if (now - since < this.#minIntervalMs) continue;
94673
+ try {
94674
+ const count = await input.removeDevices(id);
94675
+ this.#suspects.delete(id);
94676
+ removed.push(id);
94677
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94678
+ integrationId: id,
94679
+ removed: count,
94680
+ firstMissingAt: since
94681
+ } });
94682
+ } catch (err) {
94683
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94684
+ integrationId: id,
94685
+ error: errMsg(err)
94686
+ } });
94687
+ }
94688
+ }
94689
+ return {
94690
+ newSuspects,
94691
+ removed
94692
+ };
94693
+ }
94694
+ };
94695
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94696
+ async function collectOwnerIntegrationIds(input) {
94697
+ const ids = /* @__PURE__ */ new Set();
94698
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94699
+ if (d.parentDeviceId !== null) continue;
94700
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94701
+ const owner = input.ownerOf(cfg);
94702
+ if (owner !== null) ids.add(owner);
94703
+ }
94704
+ return [...ids];
94705
+ }
94569
94706
  String.raw`(?<=^|["'(\s>,\[{])`;
94570
94707
  var MB = 1024 * 1024;
94571
94708
  1024 * MB, 3072 * MB;
@@ -94586,8 +94723,14 @@ object({
94586
94723
  */
94587
94724
  var VeluxReconciler = class {
94588
94725
  #deps;
94726
+ #sweep;
94727
+ #rerun = null;
94589
94728
  constructor(deps) {
94590
94729
  this.#deps = deps;
94730
+ this.#sweep = new OrphanIntegrationSweep({
94731
+ logger: deps.logger,
94732
+ ...deps.sweepOptions
94733
+ });
94591
94734
  }
94592
94735
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
94593
94736
  async reconcile() {
@@ -94610,6 +94753,37 @@ var VeluxReconciler = class {
94610
94753
  }
94611
94754
  const enabledIds = new Set(enabled.map((i) => i.id));
94612
94755
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
94756
+ await this.#sweepOrphans(all.map((i) => i.id));
94757
+ }
94758
+ /** Stop the scheduled second read (addon stop). */
94759
+ dispose() {
94760
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
94761
+ this.#rerun = null;
94762
+ }
94763
+ /**
94764
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
94765
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
94766
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
94767
+ */
94768
+ async #sweepOrphans(listedIds) {
94769
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
94770
+ if (ownerIntegrationIds === void 0) return;
94771
+ try {
94772
+ const ownerIds = await ownerIntegrationIds();
94773
+ if ((await this.#sweep.sweep({
94774
+ ownerIds,
94775
+ listedIds,
94776
+ removeDevices
94777
+ })).newSuspects > 0 && this.#rerun === null) {
94778
+ this.#rerun = setTimeout(() => {
94779
+ this.#rerun = null;
94780
+ this.reconcile();
94781
+ }, this.#sweep.minIntervalMs + 1e3);
94782
+ this.#rerun.unref();
94783
+ }
94784
+ } catch (err) {
94785
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
94786
+ }
94613
94787
  }
94614
94788
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
94615
94789
  async onLifecycle(kind, integrationId) {
@@ -103312,8 +103486,69 @@ var VeluxClientRegistry = class {
103312
103486
  };
103313
103487
  //#endregion
103314
103488
  //#region src/klf-probe.ts
103315
- /** Seconds allowed for the TLS handshake plus password exchange of a probe. */
103316
- var PROBE_LOGIN_TIMEOUT_S = 15;
103489
+ /** The gateway's TLS API port. */
103490
+ var KLF_PORT = 51200;
103491
+ /**
103492
+ * Defaults add up, worst case, to 3 + 2 x 5 + 2 + 2 = 17 s, under the
103493
+ * `CONNECTION_TEST_TIMEOUT_MS` ceiling of the wizard, so a stuck gateway is named
103494
+ * here instead of the wizard giving up unnamed.
103495
+ */
103496
+ var DEFAULT_KLF_PROBE_TIMEOUTS_MS = {
103497
+ tcp: 3e3,
103498
+ login: 5e3,
103499
+ version: 2e3,
103500
+ logout: 2e3
103501
+ };
103502
+ var PHASE_TEXT = {
103503
+ tcp: "did not accept a TCP connection on port 51200",
103504
+ login: "did not complete the secure login (TLS handshake or password answer)",
103505
+ version: "did not answer the version request",
103506
+ logout: "did not close the session"
103507
+ };
103508
+ /** A probe step ran out of time. Names the step, so a bound is never a silent hang. */
103509
+ var KlfProbeTimeoutError = class extends Error {
103510
+ phase;
103511
+ host;
103512
+ limitMs;
103513
+ constructor(phase, host, limitMs) {
103514
+ super(`The gateway ${host} ${PHASE_TEXT[phase]} within ${Math.round(limitMs / 100) / 10}s (timed out)`);
103515
+ this.phase = phase;
103516
+ this.host = host;
103517
+ this.limitMs = limitMs;
103518
+ this.name = "KlfProbeTimeoutError";
103519
+ }
103520
+ };
103521
+ async function withDeadline(work, ms, onTimeout) {
103522
+ let timer;
103523
+ try {
103524
+ return await Promise.race([work, new Promise((_resolve, reject) => {
103525
+ timer = setTimeout(() => reject(onTimeout()), ms);
103526
+ })]);
103527
+ } finally {
103528
+ clearTimeout(timer);
103529
+ }
103530
+ }
103531
+ /** A bare TCP connect to the gateway port: tells "nothing listens" from "TLS is stuck". */
103532
+ function tcpReachable(host, ms) {
103533
+ return new Promise((resolve, reject) => {
103534
+ const socket = (0, node_net.connect)({
103535
+ host,
103536
+ port: KLF_PORT
103537
+ });
103538
+ socket.setTimeout(ms, () => {
103539
+ socket.destroy();
103540
+ reject(new KlfProbeTimeoutError("tcp", host, ms));
103541
+ });
103542
+ socket.once("connect", () => {
103543
+ socket.destroy();
103544
+ resolve();
103545
+ });
103546
+ socket.once("error", (err) => {
103547
+ socket.destroy();
103548
+ reject(err);
103549
+ });
103550
+ });
103551
+ }
103317
103552
  /**
103318
103553
  * Opens ONE session, authenticates, reads the firmware version and closes the
103319
103554
  * session again, whatever happens. The gateway accepts two sessions in total, so
@@ -103321,28 +103556,46 @@ var PROBE_LOGIN_TIMEOUT_S = 15;
103321
103556
  * list. A refused password rejects with `VeluxLoginRefusedError`.
103322
103557
  */
103323
103558
  async function probeKlfGateway(options) {
103559
+ const limits = {
103560
+ ...DEFAULT_KLF_PROBE_TIMEOUTS_MS,
103561
+ ...options.timeoutsMs
103562
+ };
103563
+ await tcpReachable(options.host, limits.tcp);
103324
103564
  const connection = options.tls === void 0 ? new Connection(options.host) : new Connection(options.host, options.tls);
103565
+ const loginError = () => new KlfProbeTimeoutError("login", options.host, limits.login);
103325
103566
  try {
103326
103567
  try {
103327
- await connection.loginAsync(options.password, PROBE_LOGIN_TIMEOUT_S);
103568
+ await withDeadline(connection.loginAsync(options.password, limits.login / 1e3), limits.login * 2 + 500, loginError);
103328
103569
  } catch (err) {
103570
+ if (err instanceof KlfProbeTimeoutError) throw err;
103571
+ if (/^timeout$/i.test(errMsg(err)) || err instanceof Error && err.name === "TimeoutError") throw loginError();
103329
103572
  throw loginFailure(err);
103330
103573
  }
103331
103574
  try {
103332
- const { SoftwareVersion } = await new Gateway(connection).getVersionAsync();
103575
+ const { SoftwareVersion } = await withDeadline(new Gateway(connection).getVersionAsync(), limits.version, () => new KlfProbeTimeoutError("version", options.host, limits.version));
103333
103576
  return { firmware: SoftwareVersion.toString() };
103334
103577
  } catch {
103335
103578
  return { firmware: null };
103336
103579
  }
103337
103580
  } finally {
103338
103581
  try {
103339
- await connection.logoutAsync();
103582
+ await withDeadline(connection.logoutAsync(limits.logout / 1e3), limits.logout + 500, () => new KlfProbeTimeoutError("logout", options.host, limits.logout));
103340
103583
  } catch (err) {
103341
103584
  errMsg(err);
103585
+ await connection[Symbol.asyncDispose]().catch(() => void 0);
103342
103586
  }
103343
103587
  }
103344
103588
  }
103345
103589
  var TEST_LABEL = "Logs in to the gateway with this password, then closes the session";
103590
+ /** Under `CONNECTION_TEST_TIMEOUT_MS`, so the reason reaches the operator first. */
103591
+ var DEFAULT_TEST_DEADLINE_MS = CONNECTION_TEST_TIMEOUT_MS - 2e3;
103592
+ /** The whole test outlived its ceiling; no step of the probe answered in time. */
103593
+ var KlfTestDeadlineError = class extends Error {
103594
+ constructor(host, limitMs) {
103595
+ super(`The gateway ${host} did not answer within ${Math.round(limitMs / 1e3)}s (timed out)`);
103596
+ this.name = "KlfTestDeadlineError";
103597
+ }
103598
+ };
103346
103599
  var UNREACHABLE_CODES = new Set([
103347
103600
  "ECONNREFUSED",
103348
103601
  "EHOSTUNREACH",
@@ -103364,6 +103617,10 @@ function classifyProbeFailure(err) {
103364
103617
  kind: "wrong-password",
103365
103618
  text: err.message
103366
103619
  };
103620
+ if (err instanceof KlfProbeTimeoutError || err instanceof KlfTestDeadlineError) return {
103621
+ kind: "unreachable",
103622
+ text: err.message
103623
+ };
103367
103624
  const text = typeof err === "string" ? err : errMsg(err);
103368
103625
  const code = typeof err === "object" && err !== null ? Reflect.get(err, "code") : void 0;
103369
103626
  if (typeof code === "string" && UNREACHABLE_CODES.has(code)) return {
@@ -103388,7 +103645,7 @@ function classifyProbeFailure(err) {
103388
103645
  };
103389
103646
  }
103390
103647
  function buildVeluxConnectionTestProvider(deps) {
103391
- const { logger, probe = probeKlfGateway, liveSessions = () => 0 } = deps;
103648
+ const { logger, probe = probeKlfGateway, liveSessions = () => 0, deadlineMs = DEFAULT_TEST_DEADLINE_MS } = deps;
103392
103649
  /** The tail of the test queue per host: a second test waits for the first to log out. */
103393
103650
  const queues = /* @__PURE__ */ new Map();
103394
103651
  async function run(host, password) {
@@ -103404,10 +103661,13 @@ function buildVeluxConnectionTestProvider(deps) {
103404
103661
  }
103405
103662
  const startedAt = Date.now();
103406
103663
  try {
103407
- const result = await probe({
103664
+ let timer;
103665
+ const result = await Promise.race([probe({
103408
103666
  host,
103409
103667
  password
103410
- });
103668
+ }), new Promise((_resolve, reject) => {
103669
+ timer = setTimeout(() => reject(new KlfTestDeadlineError(host, deadlineMs)), deadlineMs);
103670
+ })]).finally(() => clearTimeout(timer));
103411
103671
  return {
103412
103672
  outcome: "validated",
103413
103673
  latencyMs: Date.now() - startedAt,
@@ -103642,7 +103902,12 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103642
103902
  ],
103643
103903
  customActions: veluxActions,
103644
103904
  actionHandlers: { discoverGateway: async () => {
103905
+ const startedAt = Date.now();
103645
103906
  const { candidates } = await this.ctx.api.deviceManager.discoverProvider.mutate({ addonId: this.ctx.id });
103907
+ this.ctx.logger.info("Velux gateway scan finished", { meta: {
103908
+ found: candidates.length,
103909
+ ms: Date.now() - startedAt
103910
+ } });
103646
103911
  const hosts = candidates.map((candidate) => ({
103647
103912
  host: String(candidate.prefilledConfig["host"] ?? ""),
103648
103913
  name: candidate.suggestedName,
@@ -103662,6 +103927,7 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103662
103927
  this.ctx.logger.warn("Velux: shutdown error", { meta: { error: errMsg(err) } });
103663
103928
  }
103664
103929
  this.clients = null;
103930
+ this.integrationReconciler?.dispose();
103665
103931
  this.integrationReconciler = null;
103666
103932
  setKlfLibraryLogSink(null);
103667
103933
  await super.onShutdown();
@@ -103746,7 +104012,8 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103746
104012
  await this.requireClients().upsert(integration.id, integration.name, connection);
103747
104013
  return true;
103748
104014
  },
103749
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
104015
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
104016
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
103750
104017
  });
103751
104018
  return this.integrationReconciler;
103752
104019
  }
@@ -103766,6 +104033,21 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103766
104033
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
103767
104034
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
103768
104035
  }
104036
+ /** The integrations that own devices here, for the orphan sweep. */
104037
+ async ownerIntegrationIds() {
104038
+ const reg = this.ctx.kernel.deviceRegistry;
104039
+ const devices = this.ctx.kernel.devices;
104040
+ if (!reg || !devices) return [];
104041
+ return await collectOwnerIntegrationIds({
104042
+ reg,
104043
+ devices,
104044
+ addonId: this.addonId,
104045
+ ownerOf: (cfg) => {
104046
+ const owner = cfg["integrationId"];
104047
+ return cfg["system"] === "velux" && typeof owner === "string" ? owner : null;
104048
+ }
104049
+ });
104050
+ }
103769
104051
  async cascadeRemoveDevicesForIntegration(integrationId) {
103770
104052
  const reg = this.ctx.kernel.deviceRegistry;
103771
104053
  const devices = this.ctx.kernel.devices;
package/dist/addon.mjs CHANGED
@@ -1,6 +1,7 @@
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 { connect as connect$1 } from "node:net";
4
5
  //#region \0rolldown/runtime.js
5
6
  var __create = Object.create;
6
7
  var __defProp = Object.defineProperty;
@@ -66415,6 +66416,60 @@ onColorChanged: { data: object({
66415
66416
  */
66416
66417
  history: "none"
66417
66418
  };
66419
+ /**
66420
+ * `connection-test` — pre-creation validation of an integration's settings,
66421
+ * system-scoped collection (one provider per integration addon).
66422
+ *
66423
+ * ── Why this cap exists ─────────────────────────────────────────────────────
66424
+ *
66425
+ * Before it, `integrations.testConnection` had exactly two behaviours: a broker
66426
+ * branch (`settings.brokerId` → `broker.testConnection`) and a DEFAULT branch
66427
+ * that probed `settings.main_stream_url ?? settings.url` with ffprobe. Every
66428
+ * account-based integration — Dreo, Dreame, Tuya, Petkit, Wyze — fell into that
66429
+ * default and was told `{"success":false,"error":"No stream URL provided"}`.
66430
+ * That answer neither validated nor refused anything, so:
66431
+ *
66432
+ * - the Test button had NEVER worked for an account integration, and
66433
+ * - `integrations.create` had nothing to gate on, so an integration with a
66434
+ * wrong password was created happily and failed later, silently, at
66435
+ * reconcile time.
66436
+ *
66437
+ * The structural gap was that at CREATE time the integration does not exist
66438
+ * yet: there is no `integrationId`, no `brokerId`, no live client — and every
66439
+ * existing provider surface (`device-provider`, `device-adoption`) is keyed by
66440
+ * one of those. There was no way to ask "are these settings valid?" before the
66441
+ * row existed.
66442
+ *
66443
+ * ── The contract ────────────────────────────────────────────────────────────
66444
+ *
66445
+ * The provider knows how to validate its OWN settings. The framework does not
66446
+ * guess from the shape of the settings — a stream-URL probe is one provider's
66447
+ * implementation of this cap, not the universal fallback. A provider that does
66448
+ * not register this cap is a KNOWN "cannot validate", never a silent pass.
66449
+ *
66450
+ * `testSettings` takes the candidate settings blob exactly as the wizard's
66451
+ * config step collected it (the same blob that would be handed to
66452
+ * `integrations.create`), and MUST NOT persist anything, mint an integration,
66453
+ * or mutate live state. It opens a throwaway session, asks, and closes it.
66454
+ *
66455
+ * ── The three outcomes are NOT interchangeable ──────────────────────────────
66456
+ *
66457
+ * This is the whole point of the discriminated union: a `null` from a timeout
66458
+ * must never look like a `null` from a refusal.
66459
+ *
66460
+ * - `validated` — the remote ACCEPTED these credentials. Observed.
66461
+ * - `rejected` — the remote REFUSED these credentials. Observed.
66462
+ * This is the ONLY outcome that blocks creation.
66463
+ * - `inconclusive` — the check could not complete (DNS, timeout, 5xx, an
66464
+ * unexpected shape). NOTHING was observed about the
66465
+ * credentials. Never rendered, or counted, as a failure.
66466
+ *
66467
+ * A provider that cannot tell a refusal from a transport fault must return
66468
+ * `inconclusive`. Guessing `rejected` would block creation on a flaky network;
66469
+ * guessing `validated` would wave a wrong password through.
66470
+ */
66471
+ /** Ceiling on how long a pre-creation probe may hold the wizard. */
66472
+ var CONNECTION_TEST_TIMEOUT_MS = 2e4;
66418
66473
  var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
66419
66474
  object({
66420
66475
  outcome: literal("validated"),
@@ -94567,6 +94622,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
94567
94622
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
94568
94623
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
94569
94624
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
94625
+ /**
94626
+ * Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
94627
+ * event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
94628
+ * devices go (through the provider's own removeDevices) when a SECOND answered read, at least
94629
+ * `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
94630
+ */
94631
+ var OrphanIntegrationSweep = class {
94632
+ #logger;
94633
+ #minIntervalMs;
94634
+ #now;
94635
+ #suspects = /* @__PURE__ */ new Map();
94636
+ constructor(options) {
94637
+ this.#logger = options.logger;
94638
+ this.#minIntervalMs = options.minIntervalMs ?? 6e4;
94639
+ this.#now = options.now ?? Date.now;
94640
+ }
94641
+ get minIntervalMs() {
94642
+ return this.#minIntervalMs;
94643
+ }
94644
+ async sweep(input) {
94645
+ if (input.listedIds === null) return {
94646
+ newSuspects: 0,
94647
+ removed: []
94648
+ };
94649
+ const listed = new Set(input.listedIds);
94650
+ const owners = new Set(input.ownerIds);
94651
+ const now = this.#now();
94652
+ let newSuspects = 0;
94653
+ const removed = [];
94654
+ for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
94655
+ this.#suspects.delete(id);
94656
+ this.#logger.info("Orphan integration suspect cleared", { meta: {
94657
+ integrationId: id,
94658
+ reason: listed.has(id) ? "listed again" : "owns no devices"
94659
+ } });
94660
+ }
94661
+ for (const id of owners) {
94662
+ if (listed.has(id)) continue;
94663
+ const since = this.#suspects.get(id);
94664
+ if (since === void 0) {
94665
+ this.#suspects.set(id, now);
94666
+ newSuspects += 1;
94667
+ this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
94668
+ integrationId: id,
94669
+ minIntervalMs: this.#minIntervalMs
94670
+ } });
94671
+ continue;
94672
+ }
94673
+ if (now - since < this.#minIntervalMs) continue;
94674
+ try {
94675
+ const count = await input.removeDevices(id);
94676
+ this.#suspects.delete(id);
94677
+ removed.push(id);
94678
+ this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
94679
+ integrationId: id,
94680
+ removed: count,
94681
+ firstMissingAt: since
94682
+ } });
94683
+ } catch (err) {
94684
+ this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
94685
+ integrationId: id,
94686
+ error: errMsg(err)
94687
+ } });
94688
+ }
94689
+ }
94690
+ return {
94691
+ newSuspects,
94692
+ removed
94693
+ };
94694
+ }
94695
+ };
94696
+ /** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
94697
+ async function collectOwnerIntegrationIds(input) {
94698
+ const ids = /* @__PURE__ */ new Set();
94699
+ for (const d of input.reg.getAllForAddon(input.addonId)) {
94700
+ if (d.parentDeviceId !== null) continue;
94701
+ const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
94702
+ const owner = input.ownerOf(cfg);
94703
+ if (owner !== null) ids.add(owner);
94704
+ }
94705
+ return [...ids];
94706
+ }
94570
94707
  String.raw`(?<=^|["'(\s>,\[{])`;
94571
94708
  var MB = 1024 * 1024;
94572
94709
  1024 * MB, 3072 * MB;
@@ -94587,8 +94724,14 @@ object({
94587
94724
  */
94588
94725
  var VeluxReconciler = class {
94589
94726
  #deps;
94727
+ #sweep;
94728
+ #rerun = null;
94590
94729
  constructor(deps) {
94591
94730
  this.#deps = deps;
94731
+ this.#sweep = new OrphanIntegrationSweep({
94732
+ logger: deps.logger,
94733
+ ...deps.sweepOptions
94734
+ });
94592
94735
  }
94593
94736
  /** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
94594
94737
  async reconcile() {
@@ -94611,6 +94754,37 @@ var VeluxReconciler = class {
94611
94754
  }
94612
94755
  const enabledIds = new Set(enabled.map((i) => i.id));
94613
94756
  for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
94757
+ await this.#sweepOrphans(all.map((i) => i.id));
94758
+ }
94759
+ /** Stop the scheduled second read (addon stop). */
94760
+ dispose() {
94761
+ if (this.#rerun !== null) clearTimeout(this.#rerun);
94762
+ this.#rerun = null;
94763
+ }
94764
+ /**
94765
+ * An integration deleted while this addon was down emits nothing it sees: its id owns devices
94766
+ * yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
94767
+ * re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
94768
+ */
94769
+ async #sweepOrphans(listedIds) {
94770
+ const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
94771
+ if (ownerIntegrationIds === void 0) return;
94772
+ try {
94773
+ const ownerIds = await ownerIntegrationIds();
94774
+ if ((await this.#sweep.sweep({
94775
+ ownerIds,
94776
+ listedIds,
94777
+ removeDevices
94778
+ })).newSuspects > 0 && this.#rerun === null) {
94779
+ this.#rerun = setTimeout(() => {
94780
+ this.#rerun = null;
94781
+ this.reconcile();
94782
+ }, this.#sweep.minIntervalMs + 1e3);
94783
+ this.#rerun.unref();
94784
+ }
94785
+ } catch (err) {
94786
+ logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
94787
+ }
94614
94788
  }
94615
94789
  /** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
94616
94790
  async onLifecycle(kind, integrationId) {
@@ -103313,8 +103487,69 @@ var VeluxClientRegistry = class {
103313
103487
  };
103314
103488
  //#endregion
103315
103489
  //#region src/klf-probe.ts
103316
- /** Seconds allowed for the TLS handshake plus password exchange of a probe. */
103317
- var PROBE_LOGIN_TIMEOUT_S = 15;
103490
+ /** The gateway's TLS API port. */
103491
+ var KLF_PORT = 51200;
103492
+ /**
103493
+ * Defaults add up, worst case, to 3 + 2 x 5 + 2 + 2 = 17 s, under the
103494
+ * `CONNECTION_TEST_TIMEOUT_MS` ceiling of the wizard, so a stuck gateway is named
103495
+ * here instead of the wizard giving up unnamed.
103496
+ */
103497
+ var DEFAULT_KLF_PROBE_TIMEOUTS_MS = {
103498
+ tcp: 3e3,
103499
+ login: 5e3,
103500
+ version: 2e3,
103501
+ logout: 2e3
103502
+ };
103503
+ var PHASE_TEXT = {
103504
+ tcp: "did not accept a TCP connection on port 51200",
103505
+ login: "did not complete the secure login (TLS handshake or password answer)",
103506
+ version: "did not answer the version request",
103507
+ logout: "did not close the session"
103508
+ };
103509
+ /** A probe step ran out of time. Names the step, so a bound is never a silent hang. */
103510
+ var KlfProbeTimeoutError = class extends Error {
103511
+ phase;
103512
+ host;
103513
+ limitMs;
103514
+ constructor(phase, host, limitMs) {
103515
+ super(`The gateway ${host} ${PHASE_TEXT[phase]} within ${Math.round(limitMs / 100) / 10}s (timed out)`);
103516
+ this.phase = phase;
103517
+ this.host = host;
103518
+ this.limitMs = limitMs;
103519
+ this.name = "KlfProbeTimeoutError";
103520
+ }
103521
+ };
103522
+ async function withDeadline(work, ms, onTimeout) {
103523
+ let timer;
103524
+ try {
103525
+ return await Promise.race([work, new Promise((_resolve, reject) => {
103526
+ timer = setTimeout(() => reject(onTimeout()), ms);
103527
+ })]);
103528
+ } finally {
103529
+ clearTimeout(timer);
103530
+ }
103531
+ }
103532
+ /** A bare TCP connect to the gateway port: tells "nothing listens" from "TLS is stuck". */
103533
+ function tcpReachable(host, ms) {
103534
+ return new Promise((resolve, reject) => {
103535
+ const socket = connect$1({
103536
+ host,
103537
+ port: KLF_PORT
103538
+ });
103539
+ socket.setTimeout(ms, () => {
103540
+ socket.destroy();
103541
+ reject(new KlfProbeTimeoutError("tcp", host, ms));
103542
+ });
103543
+ socket.once("connect", () => {
103544
+ socket.destroy();
103545
+ resolve();
103546
+ });
103547
+ socket.once("error", (err) => {
103548
+ socket.destroy();
103549
+ reject(err);
103550
+ });
103551
+ });
103552
+ }
103318
103553
  /**
103319
103554
  * Opens ONE session, authenticates, reads the firmware version and closes the
103320
103555
  * session again, whatever happens. The gateway accepts two sessions in total, so
@@ -103322,28 +103557,46 @@ var PROBE_LOGIN_TIMEOUT_S = 15;
103322
103557
  * list. A refused password rejects with `VeluxLoginRefusedError`.
103323
103558
  */
103324
103559
  async function probeKlfGateway(options) {
103560
+ const limits = {
103561
+ ...DEFAULT_KLF_PROBE_TIMEOUTS_MS,
103562
+ ...options.timeoutsMs
103563
+ };
103564
+ await tcpReachable(options.host, limits.tcp);
103325
103565
  const connection = options.tls === void 0 ? new Connection(options.host) : new Connection(options.host, options.tls);
103566
+ const loginError = () => new KlfProbeTimeoutError("login", options.host, limits.login);
103326
103567
  try {
103327
103568
  try {
103328
- await connection.loginAsync(options.password, PROBE_LOGIN_TIMEOUT_S);
103569
+ await withDeadline(connection.loginAsync(options.password, limits.login / 1e3), limits.login * 2 + 500, loginError);
103329
103570
  } catch (err) {
103571
+ if (err instanceof KlfProbeTimeoutError) throw err;
103572
+ if (/^timeout$/i.test(errMsg(err)) || err instanceof Error && err.name === "TimeoutError") throw loginError();
103330
103573
  throw loginFailure(err);
103331
103574
  }
103332
103575
  try {
103333
- const { SoftwareVersion } = await new Gateway(connection).getVersionAsync();
103576
+ const { SoftwareVersion } = await withDeadline(new Gateway(connection).getVersionAsync(), limits.version, () => new KlfProbeTimeoutError("version", options.host, limits.version));
103334
103577
  return { firmware: SoftwareVersion.toString() };
103335
103578
  } catch {
103336
103579
  return { firmware: null };
103337
103580
  }
103338
103581
  } finally {
103339
103582
  try {
103340
- await connection.logoutAsync();
103583
+ await withDeadline(connection.logoutAsync(limits.logout / 1e3), limits.logout + 500, () => new KlfProbeTimeoutError("logout", options.host, limits.logout));
103341
103584
  } catch (err) {
103342
103585
  errMsg(err);
103586
+ await connection[Symbol.asyncDispose]().catch(() => void 0);
103343
103587
  }
103344
103588
  }
103345
103589
  }
103346
103590
  var TEST_LABEL = "Logs in to the gateway with this password, then closes the session";
103591
+ /** Under `CONNECTION_TEST_TIMEOUT_MS`, so the reason reaches the operator first. */
103592
+ var DEFAULT_TEST_DEADLINE_MS = CONNECTION_TEST_TIMEOUT_MS - 2e3;
103593
+ /** The whole test outlived its ceiling; no step of the probe answered in time. */
103594
+ var KlfTestDeadlineError = class extends Error {
103595
+ constructor(host, limitMs) {
103596
+ super(`The gateway ${host} did not answer within ${Math.round(limitMs / 1e3)}s (timed out)`);
103597
+ this.name = "KlfTestDeadlineError";
103598
+ }
103599
+ };
103347
103600
  var UNREACHABLE_CODES = new Set([
103348
103601
  "ECONNREFUSED",
103349
103602
  "EHOSTUNREACH",
@@ -103365,6 +103618,10 @@ function classifyProbeFailure(err) {
103365
103618
  kind: "wrong-password",
103366
103619
  text: err.message
103367
103620
  };
103621
+ if (err instanceof KlfProbeTimeoutError || err instanceof KlfTestDeadlineError) return {
103622
+ kind: "unreachable",
103623
+ text: err.message
103624
+ };
103368
103625
  const text = typeof err === "string" ? err : errMsg(err);
103369
103626
  const code = typeof err === "object" && err !== null ? Reflect.get(err, "code") : void 0;
103370
103627
  if (typeof code === "string" && UNREACHABLE_CODES.has(code)) return {
@@ -103389,7 +103646,7 @@ function classifyProbeFailure(err) {
103389
103646
  };
103390
103647
  }
103391
103648
  function buildVeluxConnectionTestProvider(deps) {
103392
- const { logger, probe = probeKlfGateway, liveSessions = () => 0 } = deps;
103649
+ const { logger, probe = probeKlfGateway, liveSessions = () => 0, deadlineMs = DEFAULT_TEST_DEADLINE_MS } = deps;
103393
103650
  /** The tail of the test queue per host: a second test waits for the first to log out. */
103394
103651
  const queues = /* @__PURE__ */ new Map();
103395
103652
  async function run(host, password) {
@@ -103405,10 +103662,13 @@ function buildVeluxConnectionTestProvider(deps) {
103405
103662
  }
103406
103663
  const startedAt = Date.now();
103407
103664
  try {
103408
- const result = await probe({
103665
+ let timer;
103666
+ const result = await Promise.race([probe({
103409
103667
  host,
103410
103668
  password
103411
- });
103669
+ }), new Promise((_resolve, reject) => {
103670
+ timer = setTimeout(() => reject(new KlfTestDeadlineError(host, deadlineMs)), deadlineMs);
103671
+ })]).finally(() => clearTimeout(timer));
103412
103672
  return {
103413
103673
  outcome: "validated",
103414
103674
  latencyMs: Date.now() - startedAt,
@@ -103643,7 +103903,12 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103643
103903
  ],
103644
103904
  customActions: veluxActions,
103645
103905
  actionHandlers: { discoverGateway: async () => {
103906
+ const startedAt = Date.now();
103646
103907
  const { candidates } = await this.ctx.api.deviceManager.discoverProvider.mutate({ addonId: this.ctx.id });
103908
+ this.ctx.logger.info("Velux gateway scan finished", { meta: {
103909
+ found: candidates.length,
103910
+ ms: Date.now() - startedAt
103911
+ } });
103647
103912
  const hosts = candidates.map((candidate) => ({
103648
103913
  host: String(candidate.prefilledConfig["host"] ?? ""),
103649
103914
  name: candidate.suggestedName,
@@ -103663,6 +103928,7 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103663
103928
  this.ctx.logger.warn("Velux: shutdown error", { meta: { error: errMsg(err) } });
103664
103929
  }
103665
103930
  this.clients = null;
103931
+ this.integrationReconciler?.dispose();
103666
103932
  this.integrationReconciler = null;
103667
103933
  setKlfLibraryLogSink(null);
103668
103934
  await super.onShutdown();
@@ -103747,7 +104013,8 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103747
104013
  await this.requireClients().upsert(integration.id, integration.name, connection);
103748
104014
  return true;
103749
104015
  },
103750
- removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
104016
+ removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
104017
+ ownerIntegrationIds: () => this.ownerIntegrationIds()
103751
104018
  });
103752
104019
  return this.integrationReconciler;
103753
104020
  }
@@ -103767,6 +104034,21 @@ var VeluxProviderAddon = class extends BaseDeviceProvider {
103767
104034
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
103768
104035
  this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
103769
104036
  }
104037
+ /** The integrations that own devices here, for the orphan sweep. */
104038
+ async ownerIntegrationIds() {
104039
+ const reg = this.ctx.kernel.deviceRegistry;
104040
+ const devices = this.ctx.kernel.devices;
104041
+ if (!reg || !devices) return [];
104042
+ return await collectOwnerIntegrationIds({
104043
+ reg,
104044
+ devices,
104045
+ addonId: this.addonId,
104046
+ ownerOf: (cfg) => {
104047
+ const owner = cfg["integrationId"];
104048
+ return cfg["system"] === "velux" && typeof owner === "string" ? owner : null;
104049
+ }
104050
+ });
104051
+ }
103770
104052
  async cascadeRemoveDevicesForIntegration(integrationId) {
103771
104053
  const reg = this.ctx.kernel.deviceRegistry;
103772
104054
  const devices = this.ctx.kernel.devices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-velux",
3
- "version": "0.2.152",
3
+ "version": "0.2.154",
4
4
  "description": "Velux KLF-200 integration for CamStack — the gateway, local-gateway windows (with rain sensor) and shutter covers (positional, no tilt)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -62,7 +62,7 @@
62
62
  }
63
63
  ],
64
64
  "requires": {
65
- "server": ">=1.2.719"
65
+ "server": ">=1.2.723"
66
66
  }
67
67
  },
68
68
  "files": [