@camstack/addon-provider-unifi 0.2.152 → 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 +6 -0
- package/dist/addon.js +137 -1
- package/dist/addon.mjs +137 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ 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
|
+
|
|
10
16
|
## [0.2.152] - 2026-11-01
|
|
11
17
|
|
|
12
18
|
### Changed
|
package/dist/addon.js
CHANGED
|
@@ -94497,6 +94497,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
|
94497
94497
|
DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
|
|
94498
94498
|
DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
94499
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
|
+
}
|
|
94500
94582
|
String.raw`(?<=^|["'(\s>,\[{])`;
|
|
94501
94583
|
var MB = 1024 * 1024;
|
|
94502
94584
|
1024 * MB, 3072 * MB;
|
|
@@ -96001,8 +96083,14 @@ async function reconcileInfraFeatures(input) {
|
|
|
96001
96083
|
*/
|
|
96002
96084
|
var UnifiReconciler = class {
|
|
96003
96085
|
#deps;
|
|
96086
|
+
#sweep;
|
|
96087
|
+
#rerun = null;
|
|
96004
96088
|
constructor(deps) {
|
|
96005
96089
|
this.#deps = deps;
|
|
96090
|
+
this.#sweep = new OrphanIntegrationSweep({
|
|
96091
|
+
logger: deps.logger,
|
|
96092
|
+
...deps.sweepOptions
|
|
96093
|
+
});
|
|
96006
96094
|
}
|
|
96007
96095
|
/** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
|
|
96008
96096
|
async reconcile() {
|
|
@@ -96025,6 +96113,37 @@ var UnifiReconciler = class {
|
|
|
96025
96113
|
}
|
|
96026
96114
|
const enabledIds = new Set(enabled.map((i) => i.id));
|
|
96027
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
|
+
}
|
|
96028
96147
|
}
|
|
96029
96148
|
/** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
|
|
96030
96149
|
async onLifecycle(kind, integrationId) {
|
|
@@ -96150,6 +96269,7 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96150
96269
|
this.ctx.logger.warn("UniFi shutdown error", { meta: { error: errMsg(err) } });
|
|
96151
96270
|
}
|
|
96152
96271
|
this.clients = null;
|
|
96272
|
+
this.integrationReconciler?.dispose();
|
|
96153
96273
|
this.integrationReconciler = null;
|
|
96154
96274
|
unifiControllers.clear();
|
|
96155
96275
|
await super.onShutdown();
|
|
@@ -96184,7 +96304,8 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96184
96304
|
await reg.upsert(integration.id, integration.name, read.connection, read.credentialsLocked);
|
|
96185
96305
|
return true;
|
|
96186
96306
|
},
|
|
96187
|
-
removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
|
|
96307
|
+
removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
|
|
96308
|
+
ownerIntegrationIds: () => this.ownerIntegrationIds()
|
|
96188
96309
|
});
|
|
96189
96310
|
return this.integrationReconciler;
|
|
96190
96311
|
}
|
|
@@ -96203,6 +96324,21 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96203
96324
|
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
|
|
96204
96325
|
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
|
|
96205
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
|
+
});
|
|
96341
|
+
}
|
|
96206
96342
|
async cascadeRemoveDevicesForIntegration(integrationId) {
|
|
96207
96343
|
const reg = this.ctx.kernel.deviceRegistry;
|
|
96208
96344
|
const devices = this.ctx.kernel.devices;
|
package/dist/addon.mjs
CHANGED
|
@@ -94496,6 +94496,88 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
|
94496
94496
|
DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
|
|
94497
94497
|
DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
94498
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
|
+
}
|
|
94499
94581
|
String.raw`(?<=^|["'(\s>,\[{])`;
|
|
94500
94582
|
var MB = 1024 * 1024;
|
|
94501
94583
|
1024 * MB, 3072 * MB;
|
|
@@ -96000,8 +96082,14 @@ async function reconcileInfraFeatures(input) {
|
|
|
96000
96082
|
*/
|
|
96001
96083
|
var UnifiReconciler = class {
|
|
96002
96084
|
#deps;
|
|
96085
|
+
#sweep;
|
|
96086
|
+
#rerun = null;
|
|
96003
96087
|
constructor(deps) {
|
|
96004
96088
|
this.#deps = deps;
|
|
96089
|
+
this.#sweep = new OrphanIntegrationSweep({
|
|
96090
|
+
logger: deps.logger,
|
|
96091
|
+
...deps.sweepOptions
|
|
96092
|
+
});
|
|
96005
96093
|
}
|
|
96006
96094
|
/** Upsert a client per usable enabled integration; drop the CLIENT (never devices) of one the list answered is gone. */
|
|
96007
96095
|
async reconcile() {
|
|
@@ -96024,6 +96112,37 @@ var UnifiReconciler = class {
|
|
|
96024
96112
|
}
|
|
96025
96113
|
const enabledIds = new Set(enabled.map((i) => i.id));
|
|
96026
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
|
+
}
|
|
96027
96146
|
}
|
|
96028
96147
|
/** An explicit lifecycle event: only `deleted` / `disabled` of a named integration remove devices. */
|
|
96029
96148
|
async onLifecycle(kind, integrationId) {
|
|
@@ -96149,6 +96268,7 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96149
96268
|
this.ctx.logger.warn("UniFi shutdown error", { meta: { error: errMsg(err) } });
|
|
96150
96269
|
}
|
|
96151
96270
|
this.clients = null;
|
|
96271
|
+
this.integrationReconciler?.dispose();
|
|
96152
96272
|
this.integrationReconciler = null;
|
|
96153
96273
|
unifiControllers.clear();
|
|
96154
96274
|
await super.onShutdown();
|
|
@@ -96183,7 +96303,8 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96183
96303
|
await reg.upsert(integration.id, integration.name, read.connection, read.credentialsLocked);
|
|
96184
96304
|
return true;
|
|
96185
96305
|
},
|
|
96186
|
-
removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId)
|
|
96306
|
+
removeDevices: (integrationId) => this.cascadeRemoveDevicesForIntegration(integrationId),
|
|
96307
|
+
ownerIntegrationIds: () => this.ownerIntegrationIds()
|
|
96187
96308
|
});
|
|
96188
96309
|
return this.integrationReconciler;
|
|
96189
96310
|
}
|
|
@@ -96202,6 +96323,21 @@ var UnifiProviderAddon = class extends BaseDeviceProvider {
|
|
|
96202
96323
|
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, on("disabled"));
|
|
96203
96324
|
this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, on("deleted"));
|
|
96204
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
|
+
});
|
|
96340
|
+
}
|
|
96205
96341
|
async cascadeRemoveDevicesForIntegration(integrationId) {
|
|
96206
96342
|
const reg = this.ctx.kernel.deviceRegistry;
|
|
96207
96343
|
const devices = this.ctx.kernel.devices;
|
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",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
}
|
|
60
60
|
],
|
|
61
61
|
"requires": {
|
|
62
|
-
"server": ">=1.2.
|
|
62
|
+
"server": ">=1.2.722"
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"files": [
|