@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 +12 -0
- package/dist/addon.js +291 -9
- package/dist/addon.mjs +291 -9
- package/package.json +2 -2
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
|
-
/**
|
|
103316
|
-
var
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
103317
|
-
var
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
65
|
+
"server": ">=1.2.723"
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
"files": [
|