@camstack/addon-provider-unifi 0.2.151 → 0.2.153
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 +14 -0
- package/dist/addon.js +308 -42
- package/dist/addon.mjs +308 -42
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,11 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.153] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- An integration deleted while its provider was down now has its orphan devices removed after two concordant reads of the integration list
|
|
15
|
+
|
|
16
|
+
## [0.2.152] - 2026-11-01
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Declares the minimum CamStack server it needs; the Addons page shows a held update as requires server >= X
|
|
21
|
+
- Integration reconcile no longer removes devices when the integration list or settings read fails or is empty; devices are removed only on an explicit Deleted/Disabled event
|
|
22
|
+
|
|
10
23
|
## [0.2.151] - 2026-11-01
|
|
11
24
|
|
|
12
25
|
### Changed
|
|
13
26
|
|
|
14
27
|
- UniFi is now added as an integration: the controller connection and credentials are the integration's settings, and the controller appears as a Gateway beside its infra devices and clients instead of a Hub.
|
|
28
|
+
- Declares the minimum CamStack server it needs; the Addons page shows a held update as requires server >= X
|
|
15
29
|
|
|
16
30
|
## [0.2.150] - 2026-10-28
|
|
17
31
|
|
package/dist/addon.js
CHANGED
|
@@ -5831,7 +5831,7 @@ var ZodIssueCode = {
|
|
|
5831
5831
|
var ZodFirstPartyTypeKind;
|
|
5832
5832
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5833
5833
|
//#endregion
|
|
5834
|
-
//#region ../types/dist/sleep-
|
|
5834
|
+
//#region ../types/dist/sleep-CSYuxssh.mjs
|
|
5835
5835
|
/**
|
|
5836
5836
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5837
5837
|
* window to float samples (D455).
|
|
@@ -9114,7 +9114,7 @@ var ProfileRtspEntrySchema = object({
|
|
|
9114
9114
|
/**
|
|
9115
9115
|
* The model weight formats a catalog entry can ship. Schema-free on purpose:
|
|
9116
9116
|
* `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
|
|
9117
|
-
* (the model downloader in `@camstack/system`) needs only the list and must
|
|
9117
|
+
* (the model downloader in `@camstack/server/system`) needs only the list and must
|
|
9118
9118
|
* reach it through `@camstack/types/addon` without building the cap schema
|
|
9119
9119
|
* graph (D28).
|
|
9120
9120
|
*/
|
|
@@ -12489,6 +12489,71 @@ var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
|
|
|
12489
12489
|
var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
|
|
12490
12490
|
new Set(EVENT_OWNER_TYPES);
|
|
12491
12491
|
/**
|
|
12492
|
+
* What a published package needs from the rest of the install (D989).
|
|
12493
|
+
*
|
|
12494
|
+
* One published framework package (`@camstack/server`) carries the kernel, the
|
|
12495
|
+
* builtins and the capability contracts, so "which addon runs on which
|
|
12496
|
+
* framework" is a question of version, and the answer is DECLARED by the
|
|
12497
|
+
* package that knows it:
|
|
12498
|
+
*
|
|
12499
|
+
* - an addon says the oldest server it runs on (`requires.server: ">=X"`);
|
|
12500
|
+
* - the server says the oldest addon it works with
|
|
12501
|
+
* (`requires.addons: { "@camstack/addon-x": ">=Y" }`) and the oldest shell
|
|
12502
|
+
* around it (`requires.wrapper: { desktop, image }`);
|
|
12503
|
+
* - a wrapper (the desktop app, the container starter) says which server
|
|
12504
|
+
* range it can host, and reports its own kind and version.
|
|
12505
|
+
*
|
|
12506
|
+
* The publish workflow STAMPS these after the version bump
|
|
12507
|
+
* (`scripts/ci/stamp-requires.mjs`); nobody types a number by hand.
|
|
12508
|
+
*
|
|
12509
|
+
* A requirement that cannot be checked is NOT met: a shell that reports no
|
|
12510
|
+
* version counts as below the minimum, and an unreadable running version is
|
|
12511
|
+
* named in the result (a failed read is never a pass).
|
|
12512
|
+
*/
|
|
12513
|
+
/** `>=MAJOR.MINOR.PATCH[-prerelease]` — the only range shape a requirement uses. */
|
|
12514
|
+
var MinVersionSchema = string().regex(/^>=\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, "a minimum is written \">=X.Y.Z\"");
|
|
12515
|
+
object({
|
|
12516
|
+
/** The oldest `@camstack/server` this package runs on. */
|
|
12517
|
+
server: MinVersionSchema.optional(),
|
|
12518
|
+
/** The oldest installed version of each named addon package. */
|
|
12519
|
+
addons: record(string(), MinVersionSchema).optional(),
|
|
12520
|
+
/** The oldest shell around the server, per shell kind. */
|
|
12521
|
+
wrapper: object({
|
|
12522
|
+
desktop: MinVersionSchema.optional(),
|
|
12523
|
+
image: MinVersionSchema.optional()
|
|
12524
|
+
}).optional()
|
|
12525
|
+
});
|
|
12526
|
+
/** One unmet requirement; each names the versions on both sides. */
|
|
12527
|
+
var UnmetRequirementSchema = discriminatedUnion("kind", [
|
|
12528
|
+
object({
|
|
12529
|
+
kind: literal("server-too-old"),
|
|
12530
|
+
required: string(),
|
|
12531
|
+
/** `null` — the running server version could not be read. */
|
|
12532
|
+
running: string().nullable()
|
|
12533
|
+
}),
|
|
12534
|
+
object({
|
|
12535
|
+
kind: literal("addon-too-old"),
|
|
12536
|
+
packageName: string(),
|
|
12537
|
+
required: string(),
|
|
12538
|
+
/** `null` — the addon is not installed (or its version is unreadable). */
|
|
12539
|
+
running: string().nullable()
|
|
12540
|
+
}),
|
|
12541
|
+
object({
|
|
12542
|
+
kind: literal("wrapper-too-old"),
|
|
12543
|
+
wrapper: _enum(["desktop", "image"]),
|
|
12544
|
+
required: string(),
|
|
12545
|
+
/** `null` — the wrapper reported no version. */
|
|
12546
|
+
running: string().nullable()
|
|
12547
|
+
})
|
|
12548
|
+
]);
|
|
12549
|
+
var RequiredAddonUpdateSchema = object({
|
|
12550
|
+
packageName: string(),
|
|
12551
|
+
/** The minimum the target server declares. */
|
|
12552
|
+
required: string(),
|
|
12553
|
+
/** What this node has; `null` when the addon is not installed. */
|
|
12554
|
+
installed: string().nullable()
|
|
12555
|
+
});
|
|
12556
|
+
/**
|
|
12492
12557
|
* Which integration instance a settings form is for. An addon that serves several instances
|
|
12493
12558
|
* (`instanceMode: multiple`) can show a different form per instance — an NVR integration
|
|
12494
12559
|
* needs its connection, the default integration needs the addon's own sections.
|
|
@@ -42904,7 +42969,7 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
|
|
|
42904
42969
|
* rebuilds without manual reload.
|
|
42905
42970
|
*
|
|
42906
42971
|
* The hub-local builtin `addon-pages-aggregator` (see
|
|
42907
|
-
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
42972
|
+
* `@camstack/server/system/builtins/addon-pages-aggregator`) registers the
|
|
42908
42973
|
* provider. Splitting the public aggregator from the raw collection
|
|
42909
42974
|
* keeps both ends in codegen — there's no hand-written
|
|
42910
42975
|
* `addon-pages.router.ts` wrapper anymore.
|
|
@@ -43204,7 +43269,7 @@ method(_void(), array(WidgetMetadataSchema).readonly(), { auth: "admin" });
|
|
|
43204
43269
|
* manual reload — same scheme used by `addon-pages`.
|
|
43205
43270
|
*
|
|
43206
43271
|
* The hub-local builtin `addon-widgets-aggregator` (see
|
|
43207
|
-
* `@camstack/system/builtins/addon-widgets-aggregator`) registers the
|
|
43272
|
+
* `@camstack/server/system/builtins/addon-widgets-aggregator`) registers the
|
|
43208
43273
|
* provider. Splitting the public aggregator from the raw collection
|
|
43209
43274
|
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
43210
43275
|
*/
|
|
@@ -61404,7 +61469,18 @@ var ServerUpdateActionResultSchema = object({
|
|
|
61404
61469
|
* store (D877), present when the pre-flight refused the update
|
|
61405
61470
|
* (`reason: 'addon-would-refuse'`). Optional for version skew.
|
|
61406
61471
|
*/
|
|
61407
|
-
blockers: array(UpdateBlockerSchema).optional()
|
|
61472
|
+
blockers: array(UpdateBlockerSchema).optional(),
|
|
61473
|
+
/**
|
|
61474
|
+
* The minimums the target server declares and this node does not meet
|
|
61475
|
+
* (D989), present when the update was refused with
|
|
61476
|
+
* `reason: 'requirements-unmet'`. Each names the versions on both sides.
|
|
61477
|
+
*/
|
|
61478
|
+
unmet: array(UnmetRequirementSchema).optional(),
|
|
61479
|
+
/**
|
|
61480
|
+
* The addons this node has below the minimum the staged server declares —
|
|
61481
|
+
* the ones to update WITH the server. Present on an accepted update.
|
|
61482
|
+
*/
|
|
61483
|
+
requiredAddonUpdates: array(RequiredAddonUpdateSchema).optional()
|
|
61408
61484
|
});
|
|
61409
61485
|
var LaunchOverridesSchema = object({
|
|
61410
61486
|
/** The persisted heap ceiling in MB, or `null` when none is set. */
|
|
@@ -63948,7 +64024,9 @@ var PackageUpdateSchema = object({
|
|
|
63948
64024
|
latestVersion: string(),
|
|
63949
64025
|
category: _enum(["addon", "core"]),
|
|
63950
64026
|
requiresRestart: boolean(),
|
|
63951
|
-
isSystem: boolean()
|
|
64027
|
+
isSystem: boolean(),
|
|
64028
|
+
/** Held: what the newer version needs and this node does not meet (D989). */
|
|
64029
|
+
blockedBy: array(UnmetRequirementSchema).readonly().optional()
|
|
63952
64030
|
});
|
|
63953
64031
|
var PackageVersionInfoSchema = object({
|
|
63954
64032
|
version: string(),
|
|
@@ -69248,7 +69326,7 @@ var lawnMowerControlCapability = {
|
|
|
69248
69326
|
* clients — they reverse-connect to the hub. Exposing their interfaces
|
|
69249
69327
|
* via the same surface would leak internal topology with no upside.
|
|
69250
69328
|
*
|
|
69251
|
-
* Implementation in `@camstack/system/builtins/local-network/`.
|
|
69329
|
+
* Implementation in `@camstack/server/system/builtins/local-network/`.
|
|
69252
69330
|
*/
|
|
69253
69331
|
/** Coarse classification derived from the interface name + IP range. */
|
|
69254
69332
|
var InterfaceKindEnum = _enum([
|
|
@@ -71136,7 +71214,7 @@ onStatusChanged: { data: object({
|
|
|
71136
71214
|
* jitter, and observed/peak bandwidth per device + per client.
|
|
71137
71215
|
*
|
|
71138
71216
|
* Implementation lives in the server's `NetworkQualityService` (thin
|
|
71139
|
-
* wrapper over the shared `NetworkQualityTracker` from `@camstack/system`).
|
|
71217
|
+
* wrapper over the shared `NetworkQualityTracker` from `@camstack/server/system`).
|
|
71140
71218
|
* The provider is registered from `trpc.router.ts` against the existing
|
|
71141
71219
|
* service instance — no addon owns this state.
|
|
71142
71220
|
*
|
|
@@ -73287,7 +73365,7 @@ var ptzAutotrackCapability = {
|
|
|
73287
73365
|
* - **Native**: the camera's own firmware autotrack, registered per device by
|
|
73288
73366
|
* the driver when the probe says it exists (Reolink: yes; Hikvision:
|
|
73289
73367
|
* model-dependent).
|
|
73290
|
-
* - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
|
|
73368
|
+
* - **Wrapper**: the emulated implementation (`@camstack/server/system`'s `autotrack` builtin),
|
|
73291
73369
|
* which follows a subject with `ptz.move` pulses driven by the detection
|
|
73292
73370
|
* pipeline. `defaultActive: false` — the operator binds it per camera in
|
|
73293
73371
|
* the bindings UI.
|
|
@@ -94419,6 +94497,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
|
94419
94497
|
DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
|
|
94420
94498
|
DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
94421
94499
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
94500
|
+
/**
|
|
94501
|
+
* Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
|
|
94502
|
+
* event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
|
|
94503
|
+
* devices go (through the provider's own removeDevices) when a SECOND answered read, at least
|
|
94504
|
+
* `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
|
|
94505
|
+
*/
|
|
94506
|
+
var OrphanIntegrationSweep = class {
|
|
94507
|
+
#logger;
|
|
94508
|
+
#minIntervalMs;
|
|
94509
|
+
#now;
|
|
94510
|
+
#suspects = /* @__PURE__ */ new Map();
|
|
94511
|
+
constructor(options) {
|
|
94512
|
+
this.#logger = options.logger;
|
|
94513
|
+
this.#minIntervalMs = options.minIntervalMs ?? 6e4;
|
|
94514
|
+
this.#now = options.now ?? Date.now;
|
|
94515
|
+
}
|
|
94516
|
+
get minIntervalMs() {
|
|
94517
|
+
return this.#minIntervalMs;
|
|
94518
|
+
}
|
|
94519
|
+
async sweep(input) {
|
|
94520
|
+
if (input.listedIds === null) return {
|
|
94521
|
+
newSuspects: 0,
|
|
94522
|
+
removed: []
|
|
94523
|
+
};
|
|
94524
|
+
const listed = new Set(input.listedIds);
|
|
94525
|
+
const owners = new Set(input.ownerIds);
|
|
94526
|
+
const now = this.#now();
|
|
94527
|
+
let newSuspects = 0;
|
|
94528
|
+
const removed = [];
|
|
94529
|
+
for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
|
|
94530
|
+
this.#suspects.delete(id);
|
|
94531
|
+
this.#logger.info("Orphan integration suspect cleared", { meta: {
|
|
94532
|
+
integrationId: id,
|
|
94533
|
+
reason: listed.has(id) ? "listed again" : "owns no devices"
|
|
94534
|
+
} });
|
|
94535
|
+
}
|
|
94536
|
+
for (const id of owners) {
|
|
94537
|
+
if (listed.has(id)) continue;
|
|
94538
|
+
const since = this.#suspects.get(id);
|
|
94539
|
+
if (since === void 0) {
|
|
94540
|
+
this.#suspects.set(id, now);
|
|
94541
|
+
newSuspects += 1;
|
|
94542
|
+
this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
|
|
94543
|
+
integrationId: id,
|
|
94544
|
+
minIntervalMs: this.#minIntervalMs
|
|
94545
|
+
} });
|
|
94546
|
+
continue;
|
|
94547
|
+
}
|
|
94548
|
+
if (now - since < this.#minIntervalMs) continue;
|
|
94549
|
+
try {
|
|
94550
|
+
const count = await input.removeDevices(id);
|
|
94551
|
+
this.#suspects.delete(id);
|
|
94552
|
+
removed.push(id);
|
|
94553
|
+
this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
|
|
94554
|
+
integrationId: id,
|
|
94555
|
+
removed: count,
|
|
94556
|
+
firstMissingAt: since
|
|
94557
|
+
} });
|
|
94558
|
+
} catch (err) {
|
|
94559
|
+
this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
|
|
94560
|
+
integrationId: id,
|
|
94561
|
+
error: errMsg(err)
|
|
94562
|
+
} });
|
|
94563
|
+
}
|
|
94564
|
+
}
|
|
94565
|
+
return {
|
|
94566
|
+
newSuspects,
|
|
94567
|
+
removed
|
|
94568
|
+
};
|
|
94569
|
+
}
|
|
94570
|
+
};
|
|
94571
|
+
/** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
|
|
94572
|
+
async function collectOwnerIntegrationIds(input) {
|
|
94573
|
+
const ids = /* @__PURE__ */ new Set();
|
|
94574
|
+
for (const d of input.reg.getAllForAddon(input.addonId)) {
|
|
94575
|
+
if (d.parentDeviceId !== null) continue;
|
|
94576
|
+
const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
|
|
94577
|
+
const owner = input.ownerOf(cfg);
|
|
94578
|
+
if (owner !== null) ids.add(owner);
|
|
94579
|
+
}
|
|
94580
|
+
return [...ids];
|
|
94581
|
+
}
|
|
94422
94582
|
String.raw`(?<=^|["'(\s>,\[{])`;
|
|
94423
94583
|
var MB = 1024 * 1024;
|
|
94424
94584
|
1024 * MB, 3072 * MB;
|
|
@@ -95915,6 +96075,97 @@ async function reconcileInfraFeatures(input) {
|
|
|
95915
96075
|
return changed;
|
|
95916
96076
|
}
|
|
95917
96077
|
//#endregion
|
|
96078
|
+
//#region src/unifi-reconciler.ts
|
|
96079
|
+
/**
|
|
96080
|
+
* Keeps one live client per enabled integration. Devices are removed ONLY on an explicit
|
|
96081
|
+
* lifecycle event naming the integration (D49): a list that failed, an empty list or an
|
|
96082
|
+
* unreadable connection changes nothing about devices and is logged, never read as "gone".
|
|
96083
|
+
*/
|
|
96084
|
+
var UnifiReconciler = class {
|
|
96085
|
+
#deps;
|
|
96086
|
+
#sweep;
|
|
96087
|
+
#rerun = null;
|
|
96088
|
+
constructor(deps) {
|
|
96089
|
+
this.#deps = deps;
|
|
96090
|
+
this.#sweep = new OrphanIntegrationSweep({
|
|
96091
|
+
logger: deps.logger,
|
|
96092
|
+
...deps.sweepOptions
|
|
96093
|
+
});
|
|
96094
|
+
}
|
|
96095
|
+
/** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
|
|
96096
|
+
async reconcile() {
|
|
96097
|
+
const { logger, clients } = this.#deps;
|
|
96098
|
+
let all;
|
|
96099
|
+
try {
|
|
96100
|
+
all = await this.#deps.listIntegrations();
|
|
96101
|
+
} catch (err) {
|
|
96102
|
+
logger.warn("Unifi integration reconcile skipped — the integration list did not answer", { meta: { error: errMsg(err) } });
|
|
96103
|
+
return;
|
|
96104
|
+
}
|
|
96105
|
+
const enabled = all.filter((i) => i.addonId === this.#deps.addonId && i.enabled);
|
|
96106
|
+
for (const integration of enabled) try {
|
|
96107
|
+
if (!await this.#deps.applySettings(integration)) logger.warn("Unifi integration has no usable connection — left as it is", { meta: { integrationId: integration.id } });
|
|
96108
|
+
} catch (err) {
|
|
96109
|
+
logger.warn("Unifi reconcile failed for integration — left as it is", { meta: {
|
|
96110
|
+
integrationId: integration.id,
|
|
96111
|
+
error: errMsg(err)
|
|
96112
|
+
} });
|
|
96113
|
+
}
|
|
96114
|
+
const enabledIds = new Set(enabled.map((i) => i.id));
|
|
96115
|
+
for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
|
|
96116
|
+
await this.#sweepOrphans(all.map((i) => i.id));
|
|
96117
|
+
}
|
|
96118
|
+
/** Stop the scheduled second read (addon stop). */
|
|
96119
|
+
dispose() {
|
|
96120
|
+
if (this.#rerun !== null) clearTimeout(this.#rerun);
|
|
96121
|
+
this.#rerun = null;
|
|
96122
|
+
}
|
|
96123
|
+
/**
|
|
96124
|
+
* An integration deleted while this addon was down emits nothing it sees: its id owns devices
|
|
96125
|
+
* yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
|
|
96126
|
+
* re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
|
|
96127
|
+
*/
|
|
96128
|
+
async #sweepOrphans(listedIds) {
|
|
96129
|
+
const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
|
|
96130
|
+
if (ownerIntegrationIds === void 0) return;
|
|
96131
|
+
try {
|
|
96132
|
+
const ownerIds = await ownerIntegrationIds();
|
|
96133
|
+
if ((await this.#sweep.sweep({
|
|
96134
|
+
ownerIds,
|
|
96135
|
+
listedIds,
|
|
96136
|
+
removeDevices
|
|
96137
|
+
})).newSuspects > 0 && this.#rerun === null) {
|
|
96138
|
+
this.#rerun = setTimeout(() => {
|
|
96139
|
+
this.#rerun = null;
|
|
96140
|
+
this.reconcile();
|
|
96141
|
+
}, this.#sweep.minIntervalMs + 1e3);
|
|
96142
|
+
this.#rerun.unref();
|
|
96143
|
+
}
|
|
96144
|
+
} catch (err) {
|
|
96145
|
+
logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
|
|
96146
|
+
}
|
|
96147
|
+
}
|
|
96148
|
+
/** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
|
|
96149
|
+
async onLifecycle(kind, integrationId) {
|
|
96150
|
+
await this.reconcile();
|
|
96151
|
+
if (kind === "enabled" || integrationId === null) return;
|
|
96152
|
+
try {
|
|
96153
|
+
const removed = await this.#deps.removeDevices(integrationId);
|
|
96154
|
+
this.#deps.logger.info("Unifi integration devices removed", { meta: {
|
|
96155
|
+
integrationId,
|
|
96156
|
+
kind,
|
|
96157
|
+
removed
|
|
96158
|
+
} });
|
|
96159
|
+
} catch (err) {
|
|
96160
|
+
this.#deps.logger.warn("Unifi integration device removal failed", { meta: {
|
|
96161
|
+
integrationId,
|
|
96162
|
+
kind,
|
|
96163
|
+
error: errMsg(err)
|
|
96164
|
+
} });
|
|
96165
|
+
}
|
|
96166
|
+
}
|
|
96167
|
+
};
|
|
96168
|
+
//#endregion
|
|
95918
96169
|
//#region src/unifi-integration-device-cascade.ts
|
|
95919
96170
|
/**
|
|
95920
96171
|
* Remove every top-level device (Gateway, infra Container, client Presence) of an integration that
|
|
@@ -95992,6 +96243,7 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
95992
96243
|
[DeviceType.Sensor]: UnifiFeatureDevice,
|
|
95993
96244
|
[DeviceType.Presence]: UnifiFeatureDevice
|
|
95994
96245
|
};
|
|
96246
|
+
integrationReconciler = null;
|
|
95995
96247
|
clients = null;
|
|
95996
96248
|
constructor() {
|
|
95997
96249
|
super({});
|
|
@@ -96017,6 +96269,8 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96017
96269
|
this.ctx.logger.warn("UniFi shutdown error", { meta: { error: errMsg(err) } });
|
|
96018
96270
|
}
|
|
96019
96271
|
this.clients = null;
|
|
96272
|
+
this.integrationReconciler?.dispose();
|
|
96273
|
+
this.integrationReconciler = null;
|
|
96020
96274
|
unifiControllers.clear();
|
|
96021
96275
|
await super.onShutdown();
|
|
96022
96276
|
}
|
|
@@ -96033,51 +96287,63 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96033
96287
|
return unifiStableId(integrationId, childNativeId);
|
|
96034
96288
|
}
|
|
96035
96289
|
/**
|
|
96036
|
-
*
|
|
96037
|
-
*
|
|
96038
|
-
* runs at boot and on every integration lifecycle event.
|
|
96290
|
+
* Upsert one client per enabled integration and drop the client of one the list ANSWERED is
|
|
96291
|
+
* gone. Never removes a device (D49): that follows only an explicit lifecycle event.
|
|
96292
|
+
* Idempotent; runs at boot and on every integration lifecycle event.
|
|
96039
96293
|
*/
|
|
96040
|
-
|
|
96041
|
-
|
|
96042
|
-
|
|
96043
|
-
|
|
96044
|
-
|
|
96045
|
-
|
|
96294
|
+
reconciler() {
|
|
96295
|
+
this.integrationReconciler ??= new UnifiReconciler({
|
|
96296
|
+
addonId: this.ctx.id,
|
|
96297
|
+
logger: this.ctx.logger,
|
|
96298
|
+
clients: this.requireClients(),
|
|
96299
|
+
listIntegrations: () => this.ctx.api.integrations.list.query(),
|
|
96300
|
+
applySettings: async (integration) => {
|
|
96301
|
+
const reg = this.requireClients();
|
|
96046
96302
|
const read = connectionFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
|
|
96047
|
-
if (read === null)
|
|
96048
|
-
this.ctx.logger.warn("UniFi integration has no controller host — skipping", { meta: { integrationId: integration.id } });
|
|
96049
|
-
continue;
|
|
96050
|
-
}
|
|
96303
|
+
if (read === null) return false;
|
|
96051
96304
|
await reg.upsert(integration.id, integration.name, read.connection, read.credentialsLocked);
|
|
96052
|
-
|
|
96053
|
-
}
|
|
96054
|
-
|
|
96055
|
-
|
|
96056
|
-
|
|
96057
|
-
|
|
96058
|
-
}
|
|
96059
|
-
const vanished = reg.list().filter((id) => !surviving.has(id));
|
|
96060
|
-
await reg.retain(surviving);
|
|
96061
|
-
for (const integrationId of vanished) await this.cascadeRemoveDevicesForIntegration(integrationId);
|
|
96062
|
-
} catch (err) {
|
|
96063
|
-
this.ctx.logger.warn("UniFi integration reconcile failed", { meta: { error: errMsg(err) } });
|
|
96064
|
-
}
|
|
96305
|
+
return true;
|
|
96306
|
+
},
|
|
96307
|
+
removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
|
|
96308
|
+
ownerIntegrationIds: () => this.ownerIntegrationIds()
|
|
96309
|
+
});
|
|
96310
|
+
return this.integrationReconciler;
|
|
96065
96311
|
}
|
|
96312
|
+
async reconcileIntegrations() {
|
|
96313
|
+
await this.reconciler().reconcile();
|
|
96314
|
+
}
|
|
96315
|
+
/** Lifecycle events: only an explicit Deleted/Disabled naming an integration removes devices. */
|
|
96066
96316
|
subscribeIntegrationLifecycle() {
|
|
96067
|
-
const
|
|
96317
|
+
const on = (kind) => (event) => {
|
|
96068
96318
|
const addonId = event.data["addonId"];
|
|
96069
96319
|
if (typeof addonId === "string" && addonId !== this.ctx.id) return;
|
|
96070
|
-
|
|
96320
|
+
const integrationId = event.data["integrationId"];
|
|
96321
|
+
this.reconciler().onLifecycle(kind, typeof integrationId === "string" ? integrationId : null);
|
|
96071
96322
|
};
|
|
96072
|
-
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled },
|
|
96073
|
-
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled },
|
|
96074
|
-
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted },
|
|
96323
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, on("enabled"));
|
|
96324
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
|
|
96325
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
|
|
96326
|
+
}
|
|
96327
|
+
/** The integrations that own devices here, for the orphan sweep. */
|
|
96328
|
+
async ownerIntegrationIds() {
|
|
96329
|
+
const reg = this.ctx.kernel.deviceRegistry;
|
|
96330
|
+
const devices = this.ctx.kernel.devices;
|
|
96331
|
+
if (!reg || !devices) return [];
|
|
96332
|
+
return await collectOwnerIntegrationIds({
|
|
96333
|
+
reg,
|
|
96334
|
+
devices,
|
|
96335
|
+
addonId: this.addonId,
|
|
96336
|
+
ownerOf: (cfg) => {
|
|
96337
|
+
const owner = cfg["integrationId"];
|
|
96338
|
+
return cfg["system"] === "unifi" && typeof owner === "string" ? owner : null;
|
|
96339
|
+
}
|
|
96340
|
+
});
|
|
96075
96341
|
}
|
|
96076
96342
|
async cascadeRemoveDevicesForIntegration(integrationId) {
|
|
96077
96343
|
const reg = this.ctx.kernel.deviceRegistry;
|
|
96078
96344
|
const devices = this.ctx.kernel.devices;
|
|
96079
|
-
if (!reg || !devices) return;
|
|
96080
|
-
await cascadeRemoveDevicesForIntegration({
|
|
96345
|
+
if (!reg || !devices) return 0;
|
|
96346
|
+
return await cascadeRemoveDevicesForIntegration({
|
|
96081
96347
|
reg,
|
|
96082
96348
|
devices,
|
|
96083
96349
|
addonId: this.addonId,
|
package/dist/addon.mjs
CHANGED
|
@@ -5830,7 +5830,7 @@ var ZodIssueCode = {
|
|
|
5830
5830
|
var ZodFirstPartyTypeKind;
|
|
5831
5831
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5832
5832
|
//#endregion
|
|
5833
|
-
//#region ../types/dist/sleep-
|
|
5833
|
+
//#region ../types/dist/sleep-CSYuxssh.mjs
|
|
5834
5834
|
/**
|
|
5835
5835
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5836
5836
|
* window to float samples (D455).
|
|
@@ -9113,7 +9113,7 @@ var ProfileRtspEntrySchema = object({
|
|
|
9113
9113
|
/**
|
|
9114
9114
|
* The model weight formats a catalog entry can ship. Schema-free on purpose:
|
|
9115
9115
|
* `types/models.ts` builds Zod schemas from it, but a runner-closure consumer
|
|
9116
|
-
* (the model downloader in `@camstack/system`) needs only the list and must
|
|
9116
|
+
* (the model downloader in `@camstack/server/system`) needs only the list and must
|
|
9117
9117
|
* reach it through `@camstack/types/addon` without building the cap schema
|
|
9118
9118
|
* graph (D28).
|
|
9119
9119
|
*/
|
|
@@ -12488,6 +12488,71 @@ var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
|
|
|
12488
12488
|
var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
|
|
12489
12489
|
new Set(EVENT_OWNER_TYPES);
|
|
12490
12490
|
/**
|
|
12491
|
+
* What a published package needs from the rest of the install (D989).
|
|
12492
|
+
*
|
|
12493
|
+
* One published framework package (`@camstack/server`) carries the kernel, the
|
|
12494
|
+
* builtins and the capability contracts, so "which addon runs on which
|
|
12495
|
+
* framework" is a question of version, and the answer is DECLARED by the
|
|
12496
|
+
* package that knows it:
|
|
12497
|
+
*
|
|
12498
|
+
* - an addon says the oldest server it runs on (`requires.server: ">=X"`);
|
|
12499
|
+
* - the server says the oldest addon it works with
|
|
12500
|
+
* (`requires.addons: { "@camstack/addon-x": ">=Y" }`) and the oldest shell
|
|
12501
|
+
* around it (`requires.wrapper: { desktop, image }`);
|
|
12502
|
+
* - a wrapper (the desktop app, the container starter) says which server
|
|
12503
|
+
* range it can host, and reports its own kind and version.
|
|
12504
|
+
*
|
|
12505
|
+
* The publish workflow STAMPS these after the version bump
|
|
12506
|
+
* (`scripts/ci/stamp-requires.mjs`); nobody types a number by hand.
|
|
12507
|
+
*
|
|
12508
|
+
* A requirement that cannot be checked is NOT met: a shell that reports no
|
|
12509
|
+
* version counts as below the minimum, and an unreadable running version is
|
|
12510
|
+
* named in the result (a failed read is never a pass).
|
|
12511
|
+
*/
|
|
12512
|
+
/** `>=MAJOR.MINOR.PATCH[-prerelease]` — the only range shape a requirement uses. */
|
|
12513
|
+
var MinVersionSchema = string().regex(/^>=\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/, "a minimum is written \">=X.Y.Z\"");
|
|
12514
|
+
object({
|
|
12515
|
+
/** The oldest `@camstack/server` this package runs on. */
|
|
12516
|
+
server: MinVersionSchema.optional(),
|
|
12517
|
+
/** The oldest installed version of each named addon package. */
|
|
12518
|
+
addons: record(string(), MinVersionSchema).optional(),
|
|
12519
|
+
/** The oldest shell around the server, per shell kind. */
|
|
12520
|
+
wrapper: object({
|
|
12521
|
+
desktop: MinVersionSchema.optional(),
|
|
12522
|
+
image: MinVersionSchema.optional()
|
|
12523
|
+
}).optional()
|
|
12524
|
+
});
|
|
12525
|
+
/** One unmet requirement; each names the versions on both sides. */
|
|
12526
|
+
var UnmetRequirementSchema = discriminatedUnion("kind", [
|
|
12527
|
+
object({
|
|
12528
|
+
kind: literal("server-too-old"),
|
|
12529
|
+
required: string(),
|
|
12530
|
+
/** `null` — the running server version could not be read. */
|
|
12531
|
+
running: string().nullable()
|
|
12532
|
+
}),
|
|
12533
|
+
object({
|
|
12534
|
+
kind: literal("addon-too-old"),
|
|
12535
|
+
packageName: string(),
|
|
12536
|
+
required: string(),
|
|
12537
|
+
/** `null` — the addon is not installed (or its version is unreadable). */
|
|
12538
|
+
running: string().nullable()
|
|
12539
|
+
}),
|
|
12540
|
+
object({
|
|
12541
|
+
kind: literal("wrapper-too-old"),
|
|
12542
|
+
wrapper: _enum(["desktop", "image"]),
|
|
12543
|
+
required: string(),
|
|
12544
|
+
/** `null` — the wrapper reported no version. */
|
|
12545
|
+
running: string().nullable()
|
|
12546
|
+
})
|
|
12547
|
+
]);
|
|
12548
|
+
var RequiredAddonUpdateSchema = object({
|
|
12549
|
+
packageName: string(),
|
|
12550
|
+
/** The minimum the target server declares. */
|
|
12551
|
+
required: string(),
|
|
12552
|
+
/** What this node has; `null` when the addon is not installed. */
|
|
12553
|
+
installed: string().nullable()
|
|
12554
|
+
});
|
|
12555
|
+
/**
|
|
12491
12556
|
* Which integration instance a settings form is for. An addon that serves several instances
|
|
12492
12557
|
* (`instanceMode: multiple`) can show a different form per instance — an NVR integration
|
|
12493
12558
|
* needs its connection, the default integration needs the addon's own sections.
|
|
@@ -42903,7 +42968,7 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
|
|
|
42903
42968
|
* rebuilds without manual reload.
|
|
42904
42969
|
*
|
|
42905
42970
|
* The hub-local builtin `addon-pages-aggregator` (see
|
|
42906
|
-
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
42971
|
+
* `@camstack/server/system/builtins/addon-pages-aggregator`) registers the
|
|
42907
42972
|
* provider. Splitting the public aggregator from the raw collection
|
|
42908
42973
|
* keeps both ends in codegen — there's no hand-written
|
|
42909
42974
|
* `addon-pages.router.ts` wrapper anymore.
|
|
@@ -43203,7 +43268,7 @@ method(_void(), array(WidgetMetadataSchema).readonly(), { auth: "admin" });
|
|
|
43203
43268
|
* manual reload — same scheme used by `addon-pages`.
|
|
43204
43269
|
*
|
|
43205
43270
|
* The hub-local builtin `addon-widgets-aggregator` (see
|
|
43206
|
-
* `@camstack/system/builtins/addon-widgets-aggregator`) registers the
|
|
43271
|
+
* `@camstack/server/system/builtins/addon-widgets-aggregator`) registers the
|
|
43207
43272
|
* provider. Splitting the public aggregator from the raw collection
|
|
43208
43273
|
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
43209
43274
|
*/
|
|
@@ -61403,7 +61468,18 @@ var ServerUpdateActionResultSchema = object({
|
|
|
61403
61468
|
* store (D877), present when the pre-flight refused the update
|
|
61404
61469
|
* (`reason: 'addon-would-refuse'`). Optional for version skew.
|
|
61405
61470
|
*/
|
|
61406
|
-
blockers: array(UpdateBlockerSchema).optional()
|
|
61471
|
+
blockers: array(UpdateBlockerSchema).optional(),
|
|
61472
|
+
/**
|
|
61473
|
+
* The minimums the target server declares and this node does not meet
|
|
61474
|
+
* (D989), present when the update was refused with
|
|
61475
|
+
* `reason: 'requirements-unmet'`. Each names the versions on both sides.
|
|
61476
|
+
*/
|
|
61477
|
+
unmet: array(UnmetRequirementSchema).optional(),
|
|
61478
|
+
/**
|
|
61479
|
+
* The addons this node has below the minimum the staged server declares —
|
|
61480
|
+
* the ones to update WITH the server. Present on an accepted update.
|
|
61481
|
+
*/
|
|
61482
|
+
requiredAddonUpdates: array(RequiredAddonUpdateSchema).optional()
|
|
61407
61483
|
});
|
|
61408
61484
|
var LaunchOverridesSchema = object({
|
|
61409
61485
|
/** The persisted heap ceiling in MB, or `null` when none is set. */
|
|
@@ -63947,7 +64023,9 @@ var PackageUpdateSchema = object({
|
|
|
63947
64023
|
latestVersion: string(),
|
|
63948
64024
|
category: _enum(["addon", "core"]),
|
|
63949
64025
|
requiresRestart: boolean(),
|
|
63950
|
-
isSystem: boolean()
|
|
64026
|
+
isSystem: boolean(),
|
|
64027
|
+
/** Held: what the newer version needs and this node does not meet (D989). */
|
|
64028
|
+
blockedBy: array(UnmetRequirementSchema).readonly().optional()
|
|
63951
64029
|
});
|
|
63952
64030
|
var PackageVersionInfoSchema = object({
|
|
63953
64031
|
version: string(),
|
|
@@ -69247,7 +69325,7 @@ var lawnMowerControlCapability = {
|
|
|
69247
69325
|
* clients — they reverse-connect to the hub. Exposing their interfaces
|
|
69248
69326
|
* via the same surface would leak internal topology with no upside.
|
|
69249
69327
|
*
|
|
69250
|
-
* Implementation in `@camstack/system/builtins/local-network/`.
|
|
69328
|
+
* Implementation in `@camstack/server/system/builtins/local-network/`.
|
|
69251
69329
|
*/
|
|
69252
69330
|
/** Coarse classification derived from the interface name + IP range. */
|
|
69253
69331
|
var InterfaceKindEnum = _enum([
|
|
@@ -71135,7 +71213,7 @@ onStatusChanged: { data: object({
|
|
|
71135
71213
|
* jitter, and observed/peak bandwidth per device + per client.
|
|
71136
71214
|
*
|
|
71137
71215
|
* Implementation lives in the server's `NetworkQualityService` (thin
|
|
71138
|
-
* wrapper over the shared `NetworkQualityTracker` from `@camstack/system`).
|
|
71216
|
+
* wrapper over the shared `NetworkQualityTracker` from `@camstack/server/system`).
|
|
71139
71217
|
* The provider is registered from `trpc.router.ts` against the existing
|
|
71140
71218
|
* service instance — no addon owns this state.
|
|
71141
71219
|
*
|
|
@@ -73286,7 +73364,7 @@ var ptzAutotrackCapability = {
|
|
|
73286
73364
|
* - **Native**: the camera's own firmware autotrack, registered per device by
|
|
73287
73365
|
* the driver when the probe says it exists (Reolink: yes; Hikvision:
|
|
73288
73366
|
* model-dependent).
|
|
73289
|
-
* - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
|
|
73367
|
+
* - **Wrapper**: the emulated implementation (`@camstack/server/system`'s `autotrack` builtin),
|
|
73290
73368
|
* which follows a subject with `ptz.move` pulses driven by the detection
|
|
73291
73369
|
* pipeline. `defaultActive: false` — the operator binds it per camera in
|
|
73292
73370
|
* the bindings UI.
|
|
@@ -94418,6 +94496,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
|
94418
94496
|
DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
|
|
94419
94497
|
DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
94420
94498
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
94499
|
+
/**
|
|
94500
|
+
* Closes the gap D49 leaves: an integration deleted while its provider was down never emits an
|
|
94501
|
+
* event the provider sees. An integration id absent from an ANSWERED list is only a suspect; its
|
|
94502
|
+
* devices go (through the provider's own removeDevices) when a SECOND answered read, at least
|
|
94503
|
+
* `minIntervalMs` later, still lacks it. A read that threw changes nothing; reappearing clears.
|
|
94504
|
+
*/
|
|
94505
|
+
var OrphanIntegrationSweep = class {
|
|
94506
|
+
#logger;
|
|
94507
|
+
#minIntervalMs;
|
|
94508
|
+
#now;
|
|
94509
|
+
#suspects = /* @__PURE__ */ new Map();
|
|
94510
|
+
constructor(options) {
|
|
94511
|
+
this.#logger = options.logger;
|
|
94512
|
+
this.#minIntervalMs = options.minIntervalMs ?? 6e4;
|
|
94513
|
+
this.#now = options.now ?? Date.now;
|
|
94514
|
+
}
|
|
94515
|
+
get minIntervalMs() {
|
|
94516
|
+
return this.#minIntervalMs;
|
|
94517
|
+
}
|
|
94518
|
+
async sweep(input) {
|
|
94519
|
+
if (input.listedIds === null) return {
|
|
94520
|
+
newSuspects: 0,
|
|
94521
|
+
removed: []
|
|
94522
|
+
};
|
|
94523
|
+
const listed = new Set(input.listedIds);
|
|
94524
|
+
const owners = new Set(input.ownerIds);
|
|
94525
|
+
const now = this.#now();
|
|
94526
|
+
let newSuspects = 0;
|
|
94527
|
+
const removed = [];
|
|
94528
|
+
for (const id of [...this.#suspects.keys()]) if (listed.has(id) || !owners.has(id)) {
|
|
94529
|
+
this.#suspects.delete(id);
|
|
94530
|
+
this.#logger.info("Orphan integration suspect cleared", { meta: {
|
|
94531
|
+
integrationId: id,
|
|
94532
|
+
reason: listed.has(id) ? "listed again" : "owns no devices"
|
|
94533
|
+
} });
|
|
94534
|
+
}
|
|
94535
|
+
for (const id of owners) {
|
|
94536
|
+
if (listed.has(id)) continue;
|
|
94537
|
+
const since = this.#suspects.get(id);
|
|
94538
|
+
if (since === void 0) {
|
|
94539
|
+
this.#suspects.set(id, now);
|
|
94540
|
+
newSuspects += 1;
|
|
94541
|
+
this.#logger.info("Integration owns devices but is absent from the list — suspect", { meta: {
|
|
94542
|
+
integrationId: id,
|
|
94543
|
+
minIntervalMs: this.#minIntervalMs
|
|
94544
|
+
} });
|
|
94545
|
+
continue;
|
|
94546
|
+
}
|
|
94547
|
+
if (now - since < this.#minIntervalMs) continue;
|
|
94548
|
+
try {
|
|
94549
|
+
const count = await input.removeDevices(id);
|
|
94550
|
+
this.#suspects.delete(id);
|
|
94551
|
+
removed.push(id);
|
|
94552
|
+
this.#logger.warn("Orphan integration devices removed — absent from two answered lists", { meta: {
|
|
94553
|
+
integrationId: id,
|
|
94554
|
+
removed: count,
|
|
94555
|
+
firstMissingAt: since
|
|
94556
|
+
} });
|
|
94557
|
+
} catch (err) {
|
|
94558
|
+
this.#logger.warn("Orphan integration device removal failed — kept as suspect", { meta: {
|
|
94559
|
+
integrationId: id,
|
|
94560
|
+
error: errMsg(err)
|
|
94561
|
+
} });
|
|
94562
|
+
}
|
|
94563
|
+
}
|
|
94564
|
+
return {
|
|
94565
|
+
newSuspects,
|
|
94566
|
+
removed
|
|
94567
|
+
};
|
|
94568
|
+
}
|
|
94569
|
+
};
|
|
94570
|
+
/** The integration ids that own top-level devices of one provider (a config that cannot be read is skipped). */
|
|
94571
|
+
async function collectOwnerIntegrationIds(input) {
|
|
94572
|
+
const ids = /* @__PURE__ */ new Set();
|
|
94573
|
+
for (const d of input.reg.getAllForAddon(input.addonId)) {
|
|
94574
|
+
if (d.parentDeviceId !== null) continue;
|
|
94575
|
+
const cfg = await input.devices.loadConfig(d.id).catch(() => ({}));
|
|
94576
|
+
const owner = input.ownerOf(cfg);
|
|
94577
|
+
if (owner !== null) ids.add(owner);
|
|
94578
|
+
}
|
|
94579
|
+
return [...ids];
|
|
94580
|
+
}
|
|
94421
94581
|
String.raw`(?<=^|["'(\s>,\[{])`;
|
|
94422
94582
|
var MB = 1024 * 1024;
|
|
94423
94583
|
1024 * MB, 3072 * MB;
|
|
@@ -95914,6 +96074,97 @@ async function reconcileInfraFeatures(input) {
|
|
|
95914
96074
|
return changed;
|
|
95915
96075
|
}
|
|
95916
96076
|
//#endregion
|
|
96077
|
+
//#region src/unifi-reconciler.ts
|
|
96078
|
+
/**
|
|
96079
|
+
* Keeps one live client per enabled integration. Devices are removed ONLY on an explicit
|
|
96080
|
+
* lifecycle event naming the integration (D49): a list that failed, an empty list or an
|
|
96081
|
+
* unreadable connection changes nothing about devices and is logged, never read as "gone".
|
|
96082
|
+
*/
|
|
96083
|
+
var UnifiReconciler = class {
|
|
96084
|
+
#deps;
|
|
96085
|
+
#sweep;
|
|
96086
|
+
#rerun = null;
|
|
96087
|
+
constructor(deps) {
|
|
96088
|
+
this.#deps = deps;
|
|
96089
|
+
this.#sweep = new OrphanIntegrationSweep({
|
|
96090
|
+
logger: deps.logger,
|
|
96091
|
+
...deps.sweepOptions
|
|
96092
|
+
});
|
|
96093
|
+
}
|
|
96094
|
+
/** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
|
|
96095
|
+
async reconcile() {
|
|
96096
|
+
const { logger, clients } = this.#deps;
|
|
96097
|
+
let all;
|
|
96098
|
+
try {
|
|
96099
|
+
all = await this.#deps.listIntegrations();
|
|
96100
|
+
} catch (err) {
|
|
96101
|
+
logger.warn("Unifi integration reconcile skipped — the integration list did not answer", { meta: { error: errMsg(err) } });
|
|
96102
|
+
return;
|
|
96103
|
+
}
|
|
96104
|
+
const enabled = all.filter((i) => i.addonId === this.#deps.addonId && i.enabled);
|
|
96105
|
+
for (const integration of enabled) try {
|
|
96106
|
+
if (!await this.#deps.applySettings(integration)) logger.warn("Unifi integration has no usable connection — left as it is", { meta: { integrationId: integration.id } });
|
|
96107
|
+
} catch (err) {
|
|
96108
|
+
logger.warn("Unifi reconcile failed for integration — left as it is", { meta: {
|
|
96109
|
+
integrationId: integration.id,
|
|
96110
|
+
error: errMsg(err)
|
|
96111
|
+
} });
|
|
96112
|
+
}
|
|
96113
|
+
const enabledIds = new Set(enabled.map((i) => i.id));
|
|
96114
|
+
for (const id of clients.list()) if (!enabledIds.has(id)) await clients.remove(id);
|
|
96115
|
+
await this.#sweepOrphans(all.map((i) => i.id));
|
|
96116
|
+
}
|
|
96117
|
+
/** Stop the scheduled second read (addon stop). */
|
|
96118
|
+
dispose() {
|
|
96119
|
+
if (this.#rerun !== null) clearTimeout(this.#rerun);
|
|
96120
|
+
this.#rerun = null;
|
|
96121
|
+
}
|
|
96122
|
+
/**
|
|
96123
|
+
* An integration deleted while this addon was down emits nothing it sees: its id owns devices
|
|
96124
|
+
* yet is absent from the ANSWERED list. Removal needs a second answered read later, so one
|
|
96125
|
+
* re-run is scheduled when a suspect appears (bounded: one timer, no retry loop).
|
|
96126
|
+
*/
|
|
96127
|
+
async #sweepOrphans(listedIds) {
|
|
96128
|
+
const { ownerIntegrationIds, removeDevices, logger } = this.#deps;
|
|
96129
|
+
if (ownerIntegrationIds === void 0) return;
|
|
96130
|
+
try {
|
|
96131
|
+
const ownerIds = await ownerIntegrationIds();
|
|
96132
|
+
if ((await this.#sweep.sweep({
|
|
96133
|
+
ownerIds,
|
|
96134
|
+
listedIds,
|
|
96135
|
+
removeDevices
|
|
96136
|
+
})).newSuspects > 0 && this.#rerun === null) {
|
|
96137
|
+
this.#rerun = setTimeout(() => {
|
|
96138
|
+
this.#rerun = null;
|
|
96139
|
+
this.reconcile();
|
|
96140
|
+
}, this.#sweep.minIntervalMs + 1e3);
|
|
96141
|
+
this.#rerun.unref();
|
|
96142
|
+
}
|
|
96143
|
+
} catch (err) {
|
|
96144
|
+
logger.warn("Orphan integration sweep failed — devices left as they are", { meta: { error: errMsg(err) } });
|
|
96145
|
+
}
|
|
96146
|
+
}
|
|
96147
|
+
/** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
|
|
96148
|
+
async onLifecycle(kind, integrationId) {
|
|
96149
|
+
await this.reconcile();
|
|
96150
|
+
if (kind === "enabled" || integrationId === null) return;
|
|
96151
|
+
try {
|
|
96152
|
+
const removed = await this.#deps.removeDevices(integrationId);
|
|
96153
|
+
this.#deps.logger.info("Unifi integration devices removed", { meta: {
|
|
96154
|
+
integrationId,
|
|
96155
|
+
kind,
|
|
96156
|
+
removed
|
|
96157
|
+
} });
|
|
96158
|
+
} catch (err) {
|
|
96159
|
+
this.#deps.logger.warn("Unifi integration device removal failed", { meta: {
|
|
96160
|
+
integrationId,
|
|
96161
|
+
kind,
|
|
96162
|
+
error: errMsg(err)
|
|
96163
|
+
} });
|
|
96164
|
+
}
|
|
96165
|
+
}
|
|
96166
|
+
};
|
|
96167
|
+
//#endregion
|
|
95917
96168
|
//#region src/unifi-integration-device-cascade.ts
|
|
95918
96169
|
/**
|
|
95919
96170
|
* Remove every top-level device (Gateway, infra Container, client Presence) of an integration that
|
|
@@ -95991,6 +96242,7 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
95991
96242
|
[DeviceType.Sensor]: UnifiFeatureDevice,
|
|
95992
96243
|
[DeviceType.Presence]: UnifiFeatureDevice
|
|
95993
96244
|
};
|
|
96245
|
+
integrationReconciler = null;
|
|
95994
96246
|
clients = null;
|
|
95995
96247
|
constructor() {
|
|
95996
96248
|
super({});
|
|
@@ -96016,6 +96268,8 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96016
96268
|
this.ctx.logger.warn("UniFi shutdown error", { meta: { error: errMsg(err) } });
|
|
96017
96269
|
}
|
|
96018
96270
|
this.clients = null;
|
|
96271
|
+
this.integrationReconciler?.dispose();
|
|
96272
|
+
this.integrationReconciler = null;
|
|
96019
96273
|
unifiControllers.clear();
|
|
96020
96274
|
await super.onShutdown();
|
|
96021
96275
|
}
|
|
@@ -96032,51 +96286,63 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96032
96286
|
return unifiStableId(integrationId, childNativeId);
|
|
96033
96287
|
}
|
|
96034
96288
|
/**
|
|
96035
|
-
*
|
|
96036
|
-
*
|
|
96037
|
-
* runs at boot and on every integration lifecycle event.
|
|
96289
|
+
* Upsert one client per enabled integration and drop the client of one the list ANSWERED is
|
|
96290
|
+
* gone. Never removes a device (D49): that follows only an explicit lifecycle event.
|
|
96291
|
+
* Idempotent; runs at boot and on every integration lifecycle event.
|
|
96038
96292
|
*/
|
|
96039
|
-
|
|
96040
|
-
|
|
96041
|
-
|
|
96042
|
-
|
|
96043
|
-
|
|
96044
|
-
|
|
96293
|
+
reconciler() {
|
|
96294
|
+
this.integrationReconciler ??= new UnifiReconciler({
|
|
96295
|
+
addonId: this.ctx.id,
|
|
96296
|
+
logger: this.ctx.logger,
|
|
96297
|
+
clients: this.requireClients(),
|
|
96298
|
+
listIntegrations: () => this.ctx.api.integrations.list.query(),
|
|
96299
|
+
applySettings: async (integration) => {
|
|
96300
|
+
const reg = this.requireClients();
|
|
96045
96301
|
const read = connectionFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
|
|
96046
|
-
if (read === null)
|
|
96047
|
-
this.ctx.logger.warn("UniFi integration has no controller host — skipping", { meta: { integrationId: integration.id } });
|
|
96048
|
-
continue;
|
|
96049
|
-
}
|
|
96302
|
+
if (read === null) return false;
|
|
96050
96303
|
await reg.upsert(integration.id, integration.name, read.connection, read.credentialsLocked);
|
|
96051
|
-
|
|
96052
|
-
}
|
|
96053
|
-
|
|
96054
|
-
|
|
96055
|
-
|
|
96056
|
-
|
|
96057
|
-
}
|
|
96058
|
-
const vanished = reg.list().filter((id) => !surviving.has(id));
|
|
96059
|
-
await reg.retain(surviving);
|
|
96060
|
-
for (const integrationId of vanished) await this.cascadeRemoveDevicesForIntegration(integrationId);
|
|
96061
|
-
} catch (err) {
|
|
96062
|
-
this.ctx.logger.warn("UniFi integration reconcile failed", { meta: { error: errMsg(err) } });
|
|
96063
|
-
}
|
|
96304
|
+
return true;
|
|
96305
|
+
},
|
|
96306
|
+
removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
|
|
96307
|
+
ownerIntegrationIds: () => this.ownerIntegrationIds()
|
|
96308
|
+
});
|
|
96309
|
+
return this.integrationReconciler;
|
|
96064
96310
|
}
|
|
96311
|
+
async reconcileIntegrations() {
|
|
96312
|
+
await this.reconciler().reconcile();
|
|
96313
|
+
}
|
|
96314
|
+
/** Lifecycle events: only an explicit Deleted/Disabled naming an integration removes devices. */
|
|
96065
96315
|
subscribeIntegrationLifecycle() {
|
|
96066
|
-
const
|
|
96316
|
+
const on = (kind) => (event) => {
|
|
96067
96317
|
const addonId = event.data["addonId"];
|
|
96068
96318
|
if (typeof addonId === "string" && addonId !== this.ctx.id) return;
|
|
96069
|
-
|
|
96319
|
+
const integrationId = event.data["integrationId"];
|
|
96320
|
+
this.reconciler().onLifecycle(kind, typeof integrationId === "string" ? integrationId : null);
|
|
96070
96321
|
};
|
|
96071
|
-
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled },
|
|
96072
|
-
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled },
|
|
96073
|
-
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted },
|
|
96322
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, on("enabled"));
|
|
96323
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
|
|
96324
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
|
|
96325
|
+
}
|
|
96326
|
+
/** The integrations that own devices here, for the orphan sweep. */
|
|
96327
|
+
async ownerIntegrationIds() {
|
|
96328
|
+
const reg = this.ctx.kernel.deviceRegistry;
|
|
96329
|
+
const devices = this.ctx.kernel.devices;
|
|
96330
|
+
if (!reg || !devices) return [];
|
|
96331
|
+
return await collectOwnerIntegrationIds({
|
|
96332
|
+
reg,
|
|
96333
|
+
devices,
|
|
96334
|
+
addonId: this.addonId,
|
|
96335
|
+
ownerOf: (cfg) => {
|
|
96336
|
+
const owner = cfg["integrationId"];
|
|
96337
|
+
return cfg["system"] === "unifi" && typeof owner === "string" ? owner : null;
|
|
96338
|
+
}
|
|
96339
|
+
});
|
|
96074
96340
|
}
|
|
96075
96341
|
async cascadeRemoveDevicesForIntegration(integrationId) {
|
|
96076
96342
|
const reg = this.ctx.kernel.deviceRegistry;
|
|
96077
96343
|
const devices = this.ctx.kernel.devices;
|
|
96078
|
-
if (!reg || !devices) return;
|
|
96079
|
-
await cascadeRemoveDevicesForIntegration({
|
|
96344
|
+
if (!reg || !devices) return 0;
|
|
96345
|
+
return await cascadeRemoveDevicesForIntegration({
|
|
96080
96346
|
reg,
|
|
96081
96347
|
devices,
|
|
96082
96348
|
addonId: this.addonId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-provider-unifi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.153",
|
|
4
4
|
"description": "UniFi Network controller device-provider addon for CamStack — local-controller infra switches/APs (as containers) + network-client presence. No cameras.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -57,7 +57,10 @@
|
|
|
57
57
|
}
|
|
58
58
|
]
|
|
59
59
|
}
|
|
60
|
-
]
|
|
60
|
+
],
|
|
61
|
+
"requires": {
|
|
62
|
+
"server": ">=1.2.722"
|
|
63
|
+
}
|
|
61
64
|
},
|
|
62
65
|
"files": [
|
|
63
66
|
"dist",
|