@camstack/addon-provider-wyze 0.2.63 → 0.2.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +391 -24
- package/dist/addon.mjs +391 -24
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -12861,6 +12861,35 @@ var deviceProviderCapability = {
|
|
|
12861
12861
|
name: string(),
|
|
12862
12862
|
type: string()
|
|
12863
12863
|
}))),
|
|
12864
|
+
/**
|
|
12865
|
+
* Tear down and reconstruct ONE device in place from its persisted rows —
|
|
12866
|
+
* touching no other device this provider owns.
|
|
12867
|
+
*
|
|
12868
|
+
* The primitive `deviceManager.migrateDevice` uses to flush the two
|
|
12869
|
+
* migrated numbers: after `swapIds` the runner's live instance still
|
|
12870
|
+
* carries the PRE-swap numeric id (baked into the object, its native-cap
|
|
12871
|
+
* registrations and its log tags), and a live object cannot be renumbered.
|
|
12872
|
+
* Before this method the only flush was restarting the whole owning addon
|
|
12873
|
+
* — which took every camera the provider owns down with it (28 devices
|
|
12874
|
+
* for one migrated camera, measured 2026-09-04, and the morning of the
|
|
12875
|
+
* same day ~27 devices' native caps did not come back on their own).
|
|
12876
|
+
*
|
|
12877
|
+
* Keyed by `stableId`, deliberately: the numeric id is exactly the thing
|
|
12878
|
+
* that changes. The reply carries the id the device answers on NOW.
|
|
12879
|
+
* Implemented once in `BaseDeviceProvider` — decommission the live
|
|
12880
|
+
* instance (if any), then re-create from the persisted row: the same
|
|
12881
|
+
* teardown/rehydrate pair every graceful shutdown + boot already uses.
|
|
12882
|
+
* An RPC, never an event: a dropped event would leave the runner writing
|
|
12883
|
+
* against the wrong camera (D8).
|
|
12884
|
+
*
|
|
12885
|
+
* Construction can dial hardware, and the migrated source is
|
|
12886
|
+
* characteristically dead — the timeout covers a full activate window
|
|
12887
|
+
* rather than the 60 s default.
|
|
12888
|
+
*/
|
|
12889
|
+
reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
|
|
12890
|
+
kind: "mutation",
|
|
12891
|
+
timeoutMs: 3 * 6e4
|
|
12892
|
+
}),
|
|
12864
12893
|
supportsDiscovery: method(object({}), boolean()),
|
|
12865
12894
|
/**
|
|
12866
12895
|
* Run a network scan. `params` carries optional provider-specific scan
|
|
@@ -13188,7 +13217,8 @@ method(object({
|
|
|
13188
13217
|
targetId: number()
|
|
13189
13218
|
}), MigrateDeviceResultSchema, {
|
|
13190
13219
|
kind: "mutation",
|
|
13191
|
-
auth: "admin"
|
|
13220
|
+
auth: "admin",
|
|
13221
|
+
timeoutMs: 12 * 6e4
|
|
13192
13222
|
}), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
|
|
13193
13223
|
deviceId: number(),
|
|
13194
13224
|
name: string()
|
|
@@ -32656,6 +32686,147 @@ var BaseDevice = class {
|
|
|
32656
32686
|
}
|
|
32657
32687
|
};
|
|
32658
32688
|
/**
|
|
32689
|
+
* Delays before retry rounds 1..N — the round count IS the bound.
|
|
32690
|
+
* 10 s catches "the hub was busy for a moment"; the full schedule
|
|
32691
|
+
* (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
|
|
32692
|
+
* per attempt) covers a device-manager lock held for minutes — the
|
|
32693
|
+
* 2026-09-04 outage's migration hold was ~3.5 min.
|
|
32694
|
+
*/
|
|
32695
|
+
var DEVICE_RESTORE_RETRY_DELAYS_MS = [
|
|
32696
|
+
1e4,
|
|
32697
|
+
3e4,
|
|
32698
|
+
9e4
|
|
32699
|
+
];
|
|
32700
|
+
/** Abortable sleep — resolves early (never rejects) on abort. */
|
|
32701
|
+
function sleep$1(ms, signal) {
|
|
32702
|
+
return new Promise((resolve) => {
|
|
32703
|
+
if (signal.aborted) {
|
|
32704
|
+
resolve();
|
|
32705
|
+
return;
|
|
32706
|
+
}
|
|
32707
|
+
const onAbort = () => {
|
|
32708
|
+
clearTimeout(timer);
|
|
32709
|
+
resolve();
|
|
32710
|
+
};
|
|
32711
|
+
const timer = setTimeout(() => {
|
|
32712
|
+
signal.removeEventListener("abort", onAbort);
|
|
32713
|
+
resolve();
|
|
32714
|
+
}, ms);
|
|
32715
|
+
timer.unref?.();
|
|
32716
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
32717
|
+
});
|
|
32718
|
+
}
|
|
32719
|
+
/** Drain `items` through at most `width` concurrent lanes. `fn` must
|
|
32720
|
+
* not reject (callers wrap their own try/catch). */
|
|
32721
|
+
async function runWithConcurrency(items, width, fn) {
|
|
32722
|
+
const queue = [...items];
|
|
32723
|
+
const laneCount = Math.max(1, Math.min(width, queue.length));
|
|
32724
|
+
const lane = async () => {
|
|
32725
|
+
for (;;) {
|
|
32726
|
+
const item = queue.shift();
|
|
32727
|
+
if (item === void 0) return;
|
|
32728
|
+
await fn(item);
|
|
32729
|
+
}
|
|
32730
|
+
};
|
|
32731
|
+
await Promise.all(Array.from({ length: laneCount }, lane));
|
|
32732
|
+
}
|
|
32733
|
+
var DeviceRestoreRetryScheduler = class {
|
|
32734
|
+
#logger;
|
|
32735
|
+
#attempt;
|
|
32736
|
+
#onPermanentFailure;
|
|
32737
|
+
#delaysMs;
|
|
32738
|
+
#concurrency;
|
|
32739
|
+
#now;
|
|
32740
|
+
#abort = new AbortController();
|
|
32741
|
+
constructor(options) {
|
|
32742
|
+
this.#logger = options.logger;
|
|
32743
|
+
this.#attempt = options.attempt;
|
|
32744
|
+
this.#onPermanentFailure = options.onPermanentFailure;
|
|
32745
|
+
this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
32746
|
+
this.#concurrency = options.concurrency ?? 4;
|
|
32747
|
+
this.#now = options.now ?? Date.now;
|
|
32748
|
+
}
|
|
32749
|
+
/** Stop retrying (shutdown). Pending entries are NOT marked
|
|
32750
|
+
* permanently failed — the next boot restores them from disk. */
|
|
32751
|
+
cancel() {
|
|
32752
|
+
this.#abort.abort();
|
|
32753
|
+
}
|
|
32754
|
+
/**
|
|
32755
|
+
* Run the bounded retry rounds. Resolves when every entry has either
|
|
32756
|
+
* restored, been marked permanently failed, or the scheduler was
|
|
32757
|
+
* cancelled. Never rejects.
|
|
32758
|
+
*/
|
|
32759
|
+
async run(initialFailures) {
|
|
32760
|
+
let pending = initialFailures.map((failure) => ({
|
|
32761
|
+
saved: failure.saved,
|
|
32762
|
+
lastError: failure.error,
|
|
32763
|
+
attempts: 1
|
|
32764
|
+
}));
|
|
32765
|
+
for (let round = 0; round < this.#delaysMs.length; round += 1) {
|
|
32766
|
+
if (pending.length === 0 || this.#abort.signal.aborted) break;
|
|
32767
|
+
await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
|
|
32768
|
+
if (this.#abort.signal.aborted) break;
|
|
32769
|
+
pending = await this.#runRound(pending, round);
|
|
32770
|
+
}
|
|
32771
|
+
if (this.#abort.signal.aborted) return [];
|
|
32772
|
+
const terminal = pending.map((entry) => ({
|
|
32773
|
+
deviceId: entry.saved.id,
|
|
32774
|
+
stableId: entry.saved.stableId,
|
|
32775
|
+
type: String(entry.saved.type),
|
|
32776
|
+
attempts: entry.attempts,
|
|
32777
|
+
lastError: entry.lastError,
|
|
32778
|
+
failedAt: this.#now()
|
|
32779
|
+
}));
|
|
32780
|
+
for (const failure of terminal) this.#onPermanentFailure(failure);
|
|
32781
|
+
return terminal;
|
|
32782
|
+
}
|
|
32783
|
+
/** One retry round: parents first (phase 0), then hub-adopted
|
|
32784
|
+
* children (phase 1) — a child's attempt depends on its parent
|
|
32785
|
+
* having landed, exactly like the initial two-pass restore. */
|
|
32786
|
+
async #runRound(pending, round) {
|
|
32787
|
+
const next = [];
|
|
32788
|
+
const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
|
|
32789
|
+
const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
|
|
32790
|
+
for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
|
|
32791
|
+
if (this.#abort.signal.aborted) {
|
|
32792
|
+
next.push(entry);
|
|
32793
|
+
return;
|
|
32794
|
+
}
|
|
32795
|
+
const attemptNo = entry.attempts + 1;
|
|
32796
|
+
try {
|
|
32797
|
+
await this.#attempt(entry.saved);
|
|
32798
|
+
this.#logger.info("Device restored on retry", {
|
|
32799
|
+
tags: {
|
|
32800
|
+
deviceId: entry.saved.id,
|
|
32801
|
+
stableId: entry.saved.stableId
|
|
32802
|
+
},
|
|
32803
|
+
meta: { attempt: attemptNo }
|
|
32804
|
+
});
|
|
32805
|
+
} catch (err) {
|
|
32806
|
+
const lastError = err instanceof Error ? err.message : String(err);
|
|
32807
|
+
const remainingRetries = this.#delaysMs.length - (round + 1);
|
|
32808
|
+
this.#logger.warn("Device restore retry failed", {
|
|
32809
|
+
tags: {
|
|
32810
|
+
deviceId: entry.saved.id,
|
|
32811
|
+
stableId: entry.saved.stableId
|
|
32812
|
+
},
|
|
32813
|
+
meta: {
|
|
32814
|
+
attempt: attemptNo,
|
|
32815
|
+
remainingRetries,
|
|
32816
|
+
error: lastError
|
|
32817
|
+
}
|
|
32818
|
+
});
|
|
32819
|
+
next.push({
|
|
32820
|
+
saved: entry.saved,
|
|
32821
|
+
lastError,
|
|
32822
|
+
attempts: attemptNo
|
|
32823
|
+
});
|
|
32824
|
+
}
|
|
32825
|
+
});
|
|
32826
|
+
return next;
|
|
32827
|
+
}
|
|
32828
|
+
};
|
|
32829
|
+
/**
|
|
32659
32830
|
* Convert an IDevice to the flat DeviceSummary shape expected by the
|
|
32660
32831
|
* device-provider cap router. Shared across all providers.
|
|
32661
32832
|
*/
|
|
@@ -32704,6 +32875,7 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32704
32875
|
}];
|
|
32705
32876
|
}
|
|
32706
32877
|
async onShutdown() {
|
|
32878
|
+
this.cancelRestoreRetries();
|
|
32707
32879
|
const devices = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
32708
32880
|
for (const device of devices) try {
|
|
32709
32881
|
await this.ctx.kernel.devices?.decommission(device.id);
|
|
@@ -32721,9 +32893,16 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32721
32893
|
async start() {}
|
|
32722
32894
|
async stop() {}
|
|
32723
32895
|
async getStatus() {
|
|
32896
|
+
const all = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
32897
|
+
const summary = this.restoreFailureSummary();
|
|
32898
|
+
if (summary === null) return {
|
|
32899
|
+
connected: true,
|
|
32900
|
+
deviceCount: all.length
|
|
32901
|
+
};
|
|
32724
32902
|
return {
|
|
32725
32903
|
connected: true,
|
|
32726
|
-
deviceCount:
|
|
32904
|
+
deviceCount: all.length,
|
|
32905
|
+
error: summary
|
|
32727
32906
|
};
|
|
32728
32907
|
}
|
|
32729
32908
|
async getDevices() {
|
|
@@ -32813,8 +32992,137 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32813
32992
|
};
|
|
32814
32993
|
}
|
|
32815
32994
|
async restoreDevices(savedDevices) {
|
|
32816
|
-
await this.onRestoreDevices(savedDevices);
|
|
32817
|
-
if (savedDevices.length
|
|
32995
|
+
const report = await this.onRestoreDevices(savedDevices);
|
|
32996
|
+
if (savedDevices.length === 0) return;
|
|
32997
|
+
if (report && report.failedCount > 0) {
|
|
32998
|
+
this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
|
|
32999
|
+
return;
|
|
33000
|
+
}
|
|
33001
|
+
const restoredCount = report ? report.restoredCount : savedDevices.length;
|
|
33002
|
+
this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
|
|
33003
|
+
}
|
|
33004
|
+
/** Retry schedule. Overridable (tests use millisecond delays). */
|
|
33005
|
+
restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
33006
|
+
/** Retry lane width. See `device-restore-retry.ts` for why retries
|
|
33007
|
+
* never re-stampede full-width while the initial pass does (D167). */
|
|
33008
|
+
restoreRetryConcurrency = 4;
|
|
33009
|
+
_restoreRetryScheduler = null;
|
|
33010
|
+
_restoreRetryCompletion = null;
|
|
33011
|
+
_permanentRestoreFailures = /* @__PURE__ */ new Map();
|
|
33012
|
+
/** Settles when the background retry rounds finish (or `null` when
|
|
33013
|
+
* nothing failed). Exposed for tests and subclass diagnostics —
|
|
33014
|
+
* boot NEVER awaits this: the runner's post-init handshake goes out
|
|
33015
|
+
* with the devices that restored, and a late success is announced
|
|
33016
|
+
* through the `native-cap-change` → `updateCaps` path. */
|
|
33017
|
+
get restoreRetryCompletion() {
|
|
33018
|
+
return this._restoreRetryCompletion;
|
|
33019
|
+
}
|
|
33020
|
+
/** Devices that exhausted the retry bound this process lifetime. */
|
|
33021
|
+
get permanentRestoreFailures() {
|
|
33022
|
+
return [...this._permanentRestoreFailures.values()];
|
|
33023
|
+
}
|
|
33024
|
+
/** One-line operator-facing summary for `getStatus().error`, or
|
|
33025
|
+
* `null` when every device restored. */
|
|
33026
|
+
restoreFailureSummary() {
|
|
33027
|
+
if (this._permanentRestoreFailures.size === 0) return null;
|
|
33028
|
+
const ids = [...this._permanentRestoreFailures.keys()].join(", ");
|
|
33029
|
+
return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
|
|
33030
|
+
}
|
|
33031
|
+
cancelRestoreRetries() {
|
|
33032
|
+
this._restoreRetryScheduler?.cancel();
|
|
33033
|
+
this._restoreRetryScheduler = null;
|
|
33034
|
+
}
|
|
33035
|
+
recordPermanentRestoreFailure(failure) {
|
|
33036
|
+
this._permanentRestoreFailures.set(failure.deviceId, failure);
|
|
33037
|
+
this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
|
|
33038
|
+
tags: {
|
|
33039
|
+
deviceId: failure.deviceId,
|
|
33040
|
+
stableId: failure.stableId
|
|
33041
|
+
},
|
|
33042
|
+
meta: {
|
|
33043
|
+
type: failure.type,
|
|
33044
|
+
attempts: failure.attempts,
|
|
33045
|
+
error: failure.lastError
|
|
33046
|
+
}
|
|
33047
|
+
});
|
|
33048
|
+
}
|
|
33049
|
+
scheduleRestoreRetries(failures, attempt) {
|
|
33050
|
+
const scheduler = new DeviceRestoreRetryScheduler({
|
|
33051
|
+
logger: this.ctx.logger,
|
|
33052
|
+
delaysMs: this.restoreRetryDelaysMs,
|
|
33053
|
+
concurrency: this.restoreRetryConcurrency,
|
|
33054
|
+
attempt,
|
|
33055
|
+
onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
|
|
33056
|
+
});
|
|
33057
|
+
this._restoreRetryScheduler = scheduler;
|
|
33058
|
+
this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
|
|
33059
|
+
this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
33060
|
+
});
|
|
33061
|
+
}
|
|
33062
|
+
/**
|
|
33063
|
+
* Tear down and reconstruct ONE device from its persisted rows — the
|
|
33064
|
+
* `deviceProvider.reloadDevice` cap method. Persistence is never touched,
|
|
33065
|
+
* and no other device this provider owns is disturbed.
|
|
33066
|
+
*
|
|
33067
|
+
* Keyed by `stableId` because the caller's whole reason to be here is that
|
|
33068
|
+
* the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
|
|
33069
|
+
* fresh instance resolves its id through `allocateDeviceId`, which returns
|
|
33070
|
+
* whatever number the row carries NOW. The teardown is `decommission` —
|
|
33071
|
+
* exactly what a graceful shutdown runs per device (fires `removeDevice()`,
|
|
33072
|
+
* unregisters native caps, drops the registry entry) — and the rebuild is
|
|
33073
|
+
* the boot restore's own `create()` path, including its pass 2: first-class
|
|
33074
|
+
* children (hub-adopted cameras under an NVR) are decommissioned with the
|
|
33075
|
+
* parent by the cascade and must be re-created explicitly, because only
|
|
33076
|
+
* accessory children come back through `getAccessoryChildren()`.
|
|
33077
|
+
*
|
|
33078
|
+
* Reloading an accessory child directly is refused (no device class) —
|
|
33079
|
+
* reload its parent instead.
|
|
33080
|
+
*/
|
|
33081
|
+
async reloadDevice(input) {
|
|
33082
|
+
const { stableId } = input;
|
|
33083
|
+
const devices = this.ctx.kernel.devices;
|
|
33084
|
+
if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
|
|
33085
|
+
const live = (await devices.getAll()).find((d) => d.stableId === stableId);
|
|
33086
|
+
if (live) await devices.decommission(live.id);
|
|
33087
|
+
const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
|
|
33088
|
+
addonId: this.addonId,
|
|
33089
|
+
stableId
|
|
33090
|
+
});
|
|
33091
|
+
const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
|
|
33092
|
+
if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
|
|
33093
|
+
const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
|
|
33094
|
+
const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
|
|
33095
|
+
if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
|
|
33096
|
+
await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
|
|
33097
|
+
const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
|
|
33098
|
+
for (const row of rows) {
|
|
33099
|
+
if (row.parentDeviceId !== id) continue;
|
|
33100
|
+
const childType = Object.values(DeviceType).find((t) => t === row.type);
|
|
33101
|
+
const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
|
|
33102
|
+
if (!ChildClass) continue;
|
|
33103
|
+
try {
|
|
33104
|
+
await devices.create(row.stableId, ChildClass, {}, id);
|
|
33105
|
+
} catch (err) {
|
|
33106
|
+
this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
|
|
33107
|
+
tags: {
|
|
33108
|
+
deviceId: row.id,
|
|
33109
|
+
stableId: row.stableId
|
|
33110
|
+
},
|
|
33111
|
+
meta: {
|
|
33112
|
+
parentDeviceId: id,
|
|
33113
|
+
error: err instanceof Error ? err.message : String(err)
|
|
33114
|
+
}
|
|
33115
|
+
});
|
|
33116
|
+
}
|
|
33117
|
+
}
|
|
33118
|
+
this.ctx.logger.info("device reloaded in place from persisted rows", {
|
|
33119
|
+
tags: { deviceId: id },
|
|
33120
|
+
meta: {
|
|
33121
|
+
stableId,
|
|
33122
|
+
type: meta.type
|
|
33123
|
+
}
|
|
33124
|
+
});
|
|
33125
|
+
return { deviceId: id };
|
|
32818
33126
|
}
|
|
32819
33127
|
/**
|
|
32820
33128
|
* Restore devices from persisted state. Two-pass:
|
|
@@ -32840,55 +33148,108 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32840
33148
|
* accessory-spawn flow handles via the parent's
|
|
32841
33149
|
* `getAccessoryChildren()`. Override only when the default doesn't
|
|
32842
33150
|
* fit.
|
|
33151
|
+
*
|
|
33152
|
+
* A row that fails either pass is NOT terminal (D347): it is handed
|
|
33153
|
+
* to a bounded background retry (`DeviceRestoreRetryScheduler`).
|
|
33154
|
+
* Only after the bound is exhausted is the device marked permanently
|
|
33155
|
+
* failed — logged at ERROR with `tags.deviceId` and surfaced via
|
|
33156
|
+
* `getStatus().error`.
|
|
32843
33157
|
*/
|
|
32844
33158
|
async onRestoreDevices(savedDevices) {
|
|
32845
33159
|
const restored = /* @__PURE__ */ new Set();
|
|
33160
|
+
const failures = [];
|
|
33161
|
+
const attemptRestore = async (saved) => {
|
|
33162
|
+
if (restored.has(saved.id)) return;
|
|
33163
|
+
const Class = this.deviceClasses[saved.type];
|
|
33164
|
+
if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
|
|
33165
|
+
if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
|
|
33166
|
+
await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
|
|
33167
|
+
restored.add(saved.id);
|
|
33168
|
+
};
|
|
32846
33169
|
const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
|
|
32847
33170
|
const restoreOne = async (saved) => {
|
|
32848
|
-
|
|
32849
|
-
if (!Class) {
|
|
33171
|
+
if (!this.deviceClasses[saved.type]) {
|
|
32850
33172
|
this.ctx.logger.warn("No device class registered for restored type — skipping", {
|
|
32851
|
-
tags: {
|
|
33173
|
+
tags: {
|
|
33174
|
+
deviceId: saved.id,
|
|
33175
|
+
stableId: saved.stableId
|
|
33176
|
+
},
|
|
32852
33177
|
meta: { type: saved.type }
|
|
32853
33178
|
});
|
|
32854
33179
|
return;
|
|
32855
33180
|
}
|
|
32856
33181
|
try {
|
|
32857
|
-
await
|
|
32858
|
-
restored.add(saved.id);
|
|
33182
|
+
await attemptRestore(saved);
|
|
32859
33183
|
} catch (err) {
|
|
32860
|
-
|
|
32861
|
-
|
|
33184
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
33185
|
+
this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
|
|
33186
|
+
tags: {
|
|
33187
|
+
deviceId: saved.id,
|
|
33188
|
+
stableId: saved.stableId
|
|
33189
|
+
},
|
|
32862
33190
|
meta: {
|
|
32863
33191
|
type: saved.type,
|
|
32864
|
-
|
|
33192
|
+
attempt: 1,
|
|
33193
|
+
error
|
|
32865
33194
|
}
|
|
32866
33195
|
});
|
|
33196
|
+
failures.push({
|
|
33197
|
+
saved,
|
|
33198
|
+
error
|
|
33199
|
+
});
|
|
32867
33200
|
}
|
|
32868
33201
|
};
|
|
32869
33202
|
await Promise.all(topLevel.map((saved) => restoreOne(saved)));
|
|
33203
|
+
const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
|
|
32870
33204
|
const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
|
|
32871
33205
|
for (const saved of childRows) {
|
|
32872
|
-
|
|
32873
|
-
if (!Class) continue;
|
|
33206
|
+
if (!this.deviceClasses[saved.type]) continue;
|
|
32874
33207
|
if (saved.parentDeviceId === null) continue;
|
|
32875
|
-
if (
|
|
32876
|
-
|
|
32877
|
-
|
|
32878
|
-
|
|
32879
|
-
|
|
32880
|
-
|
|
33208
|
+
if (restored.has(saved.parentDeviceId)) {
|
|
33209
|
+
try {
|
|
33210
|
+
await attemptRestore(saved);
|
|
33211
|
+
} catch (err) {
|
|
33212
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
33213
|
+
this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
|
|
33214
|
+
tags: {
|
|
33215
|
+
deviceId: saved.id,
|
|
33216
|
+
stableId: saved.stableId,
|
|
33217
|
+
parentDeviceId: saved.parentDeviceId
|
|
33218
|
+
},
|
|
33219
|
+
meta: {
|
|
33220
|
+
type: saved.type,
|
|
33221
|
+
attempt: 1,
|
|
33222
|
+
error
|
|
33223
|
+
}
|
|
33224
|
+
});
|
|
33225
|
+
failures.push({
|
|
33226
|
+
saved,
|
|
33227
|
+
error
|
|
33228
|
+
});
|
|
33229
|
+
}
|
|
33230
|
+
continue;
|
|
33231
|
+
}
|
|
33232
|
+
if (failedTopLevelIds.has(saved.parentDeviceId)) {
|
|
33233
|
+
this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
|
|
32881
33234
|
tags: {
|
|
33235
|
+
deviceId: saved.id,
|
|
32882
33236
|
stableId: saved.stableId,
|
|
32883
33237
|
parentDeviceId: saved.parentDeviceId
|
|
32884
33238
|
},
|
|
32885
|
-
meta: {
|
|
32886
|
-
type: saved.type,
|
|
32887
|
-
error: err instanceof Error ? err.message : String(err)
|
|
32888
|
-
}
|
|
33239
|
+
meta: { type: saved.type }
|
|
32889
33240
|
});
|
|
33241
|
+
failures.push({
|
|
33242
|
+
saved,
|
|
33243
|
+
error: `parent device ${saved.parentDeviceId} not restored`
|
|
33244
|
+
});
|
|
33245
|
+
continue;
|
|
32890
33246
|
}
|
|
32891
33247
|
}
|
|
33248
|
+
if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
|
|
33249
|
+
return {
|
|
33250
|
+
restoredCount: restored.size,
|
|
33251
|
+
failedCount: failures.length
|
|
33252
|
+
};
|
|
32892
33253
|
}
|
|
32893
33254
|
/** Convert an IDevice to the flat DeviceSummary for the cap router. */
|
|
32894
33255
|
toSummary(device) {
|
|
@@ -34651,6 +35012,12 @@ Object.freeze({
|
|
|
34651
35012
|
addonId: null,
|
|
34652
35013
|
access: "view"
|
|
34653
35014
|
},
|
|
35015
|
+
"deviceProvider.reloadDevice": {
|
|
35016
|
+
capName: "device-provider",
|
|
35017
|
+
capScope: "system",
|
|
35018
|
+
addonId: null,
|
|
35019
|
+
access: "create"
|
|
35020
|
+
},
|
|
34654
35021
|
"deviceProvider.start": {
|
|
34655
35022
|
capName: "device-provider",
|
|
34656
35023
|
capScope: "system",
|
package/dist/addon.mjs
CHANGED
|
@@ -12840,6 +12840,35 @@ var deviceProviderCapability = {
|
|
|
12840
12840
|
name: string(),
|
|
12841
12841
|
type: string()
|
|
12842
12842
|
}))),
|
|
12843
|
+
/**
|
|
12844
|
+
* Tear down and reconstruct ONE device in place from its persisted rows —
|
|
12845
|
+
* touching no other device this provider owns.
|
|
12846
|
+
*
|
|
12847
|
+
* The primitive `deviceManager.migrateDevice` uses to flush the two
|
|
12848
|
+
* migrated numbers: after `swapIds` the runner's live instance still
|
|
12849
|
+
* carries the PRE-swap numeric id (baked into the object, its native-cap
|
|
12850
|
+
* registrations and its log tags), and a live object cannot be renumbered.
|
|
12851
|
+
* Before this method the only flush was restarting the whole owning addon
|
|
12852
|
+
* — which took every camera the provider owns down with it (28 devices
|
|
12853
|
+
* for one migrated camera, measured 2026-09-04, and the morning of the
|
|
12854
|
+
* same day ~27 devices' native caps did not come back on their own).
|
|
12855
|
+
*
|
|
12856
|
+
* Keyed by `stableId`, deliberately: the numeric id is exactly the thing
|
|
12857
|
+
* that changes. The reply carries the id the device answers on NOW.
|
|
12858
|
+
* Implemented once in `BaseDeviceProvider` — decommission the live
|
|
12859
|
+
* instance (if any), then re-create from the persisted row: the same
|
|
12860
|
+
* teardown/rehydrate pair every graceful shutdown + boot already uses.
|
|
12861
|
+
* An RPC, never an event: a dropped event would leave the runner writing
|
|
12862
|
+
* against the wrong camera (D8).
|
|
12863
|
+
*
|
|
12864
|
+
* Construction can dial hardware, and the migrated source is
|
|
12865
|
+
* characteristically dead — the timeout covers a full activate window
|
|
12866
|
+
* rather than the 60 s default.
|
|
12867
|
+
*/
|
|
12868
|
+
reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
|
|
12869
|
+
kind: "mutation",
|
|
12870
|
+
timeoutMs: 3 * 6e4
|
|
12871
|
+
}),
|
|
12843
12872
|
supportsDiscovery: method(object({}), boolean()),
|
|
12844
12873
|
/**
|
|
12845
12874
|
* Run a network scan. `params` carries optional provider-specific scan
|
|
@@ -13167,7 +13196,8 @@ method(object({
|
|
|
13167
13196
|
targetId: number()
|
|
13168
13197
|
}), MigrateDeviceResultSchema, {
|
|
13169
13198
|
kind: "mutation",
|
|
13170
|
-
auth: "admin"
|
|
13199
|
+
auth: "admin",
|
|
13200
|
+
timeoutMs: 12 * 6e4
|
|
13171
13201
|
}), method(DeviceRegisterPayloadSchema, _void(), { kind: "mutation" }), method(DeviceRemovePayloadSchema, _void(), { kind: "mutation" }), method(DevicePersistConfigPayloadSchema, _void(), { kind: "mutation" }), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), record(string(), unknown())), method(object({ deviceId: number() }), DeviceMetaSchema.nullable()), method(object({
|
|
13172
13202
|
deviceId: number(),
|
|
13173
13203
|
name: string()
|
|
@@ -32635,6 +32665,147 @@ var BaseDevice = class {
|
|
|
32635
32665
|
}
|
|
32636
32666
|
};
|
|
32637
32667
|
/**
|
|
32668
|
+
* Delays before retry rounds 1..N — the round count IS the bound.
|
|
32669
|
+
* 10 s catches "the hub was busy for a moment"; the full schedule
|
|
32670
|
+
* (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
|
|
32671
|
+
* per attempt) covers a device-manager lock held for minutes — the
|
|
32672
|
+
* 2026-09-04 outage's migration hold was ~3.5 min.
|
|
32673
|
+
*/
|
|
32674
|
+
var DEVICE_RESTORE_RETRY_DELAYS_MS = [
|
|
32675
|
+
1e4,
|
|
32676
|
+
3e4,
|
|
32677
|
+
9e4
|
|
32678
|
+
];
|
|
32679
|
+
/** Abortable sleep — resolves early (never rejects) on abort. */
|
|
32680
|
+
function sleep$1(ms, signal) {
|
|
32681
|
+
return new Promise((resolve) => {
|
|
32682
|
+
if (signal.aborted) {
|
|
32683
|
+
resolve();
|
|
32684
|
+
return;
|
|
32685
|
+
}
|
|
32686
|
+
const onAbort = () => {
|
|
32687
|
+
clearTimeout(timer);
|
|
32688
|
+
resolve();
|
|
32689
|
+
};
|
|
32690
|
+
const timer = setTimeout(() => {
|
|
32691
|
+
signal.removeEventListener("abort", onAbort);
|
|
32692
|
+
resolve();
|
|
32693
|
+
}, ms);
|
|
32694
|
+
timer.unref?.();
|
|
32695
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
32696
|
+
});
|
|
32697
|
+
}
|
|
32698
|
+
/** Drain `items` through at most `width` concurrent lanes. `fn` must
|
|
32699
|
+
* not reject (callers wrap their own try/catch). */
|
|
32700
|
+
async function runWithConcurrency(items, width, fn) {
|
|
32701
|
+
const queue = [...items];
|
|
32702
|
+
const laneCount = Math.max(1, Math.min(width, queue.length));
|
|
32703
|
+
const lane = async () => {
|
|
32704
|
+
for (;;) {
|
|
32705
|
+
const item = queue.shift();
|
|
32706
|
+
if (item === void 0) return;
|
|
32707
|
+
await fn(item);
|
|
32708
|
+
}
|
|
32709
|
+
};
|
|
32710
|
+
await Promise.all(Array.from({ length: laneCount }, lane));
|
|
32711
|
+
}
|
|
32712
|
+
var DeviceRestoreRetryScheduler = class {
|
|
32713
|
+
#logger;
|
|
32714
|
+
#attempt;
|
|
32715
|
+
#onPermanentFailure;
|
|
32716
|
+
#delaysMs;
|
|
32717
|
+
#concurrency;
|
|
32718
|
+
#now;
|
|
32719
|
+
#abort = new AbortController();
|
|
32720
|
+
constructor(options) {
|
|
32721
|
+
this.#logger = options.logger;
|
|
32722
|
+
this.#attempt = options.attempt;
|
|
32723
|
+
this.#onPermanentFailure = options.onPermanentFailure;
|
|
32724
|
+
this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
32725
|
+
this.#concurrency = options.concurrency ?? 4;
|
|
32726
|
+
this.#now = options.now ?? Date.now;
|
|
32727
|
+
}
|
|
32728
|
+
/** Stop retrying (shutdown). Pending entries are NOT marked
|
|
32729
|
+
* permanently failed — the next boot restores them from disk. */
|
|
32730
|
+
cancel() {
|
|
32731
|
+
this.#abort.abort();
|
|
32732
|
+
}
|
|
32733
|
+
/**
|
|
32734
|
+
* Run the bounded retry rounds. Resolves when every entry has either
|
|
32735
|
+
* restored, been marked permanently failed, or the scheduler was
|
|
32736
|
+
* cancelled. Never rejects.
|
|
32737
|
+
*/
|
|
32738
|
+
async run(initialFailures) {
|
|
32739
|
+
let pending = initialFailures.map((failure) => ({
|
|
32740
|
+
saved: failure.saved,
|
|
32741
|
+
lastError: failure.error,
|
|
32742
|
+
attempts: 1
|
|
32743
|
+
}));
|
|
32744
|
+
for (let round = 0; round < this.#delaysMs.length; round += 1) {
|
|
32745
|
+
if (pending.length === 0 || this.#abort.signal.aborted) break;
|
|
32746
|
+
await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
|
|
32747
|
+
if (this.#abort.signal.aborted) break;
|
|
32748
|
+
pending = await this.#runRound(pending, round);
|
|
32749
|
+
}
|
|
32750
|
+
if (this.#abort.signal.aborted) return [];
|
|
32751
|
+
const terminal = pending.map((entry) => ({
|
|
32752
|
+
deviceId: entry.saved.id,
|
|
32753
|
+
stableId: entry.saved.stableId,
|
|
32754
|
+
type: String(entry.saved.type),
|
|
32755
|
+
attempts: entry.attempts,
|
|
32756
|
+
lastError: entry.lastError,
|
|
32757
|
+
failedAt: this.#now()
|
|
32758
|
+
}));
|
|
32759
|
+
for (const failure of terminal) this.#onPermanentFailure(failure);
|
|
32760
|
+
return terminal;
|
|
32761
|
+
}
|
|
32762
|
+
/** One retry round: parents first (phase 0), then hub-adopted
|
|
32763
|
+
* children (phase 1) — a child's attempt depends on its parent
|
|
32764
|
+
* having landed, exactly like the initial two-pass restore. */
|
|
32765
|
+
async #runRound(pending, round) {
|
|
32766
|
+
const next = [];
|
|
32767
|
+
const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
|
|
32768
|
+
const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
|
|
32769
|
+
for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
|
|
32770
|
+
if (this.#abort.signal.aborted) {
|
|
32771
|
+
next.push(entry);
|
|
32772
|
+
return;
|
|
32773
|
+
}
|
|
32774
|
+
const attemptNo = entry.attempts + 1;
|
|
32775
|
+
try {
|
|
32776
|
+
await this.#attempt(entry.saved);
|
|
32777
|
+
this.#logger.info("Device restored on retry", {
|
|
32778
|
+
tags: {
|
|
32779
|
+
deviceId: entry.saved.id,
|
|
32780
|
+
stableId: entry.saved.stableId
|
|
32781
|
+
},
|
|
32782
|
+
meta: { attempt: attemptNo }
|
|
32783
|
+
});
|
|
32784
|
+
} catch (err) {
|
|
32785
|
+
const lastError = err instanceof Error ? err.message : String(err);
|
|
32786
|
+
const remainingRetries = this.#delaysMs.length - (round + 1);
|
|
32787
|
+
this.#logger.warn("Device restore retry failed", {
|
|
32788
|
+
tags: {
|
|
32789
|
+
deviceId: entry.saved.id,
|
|
32790
|
+
stableId: entry.saved.stableId
|
|
32791
|
+
},
|
|
32792
|
+
meta: {
|
|
32793
|
+
attempt: attemptNo,
|
|
32794
|
+
remainingRetries,
|
|
32795
|
+
error: lastError
|
|
32796
|
+
}
|
|
32797
|
+
});
|
|
32798
|
+
next.push({
|
|
32799
|
+
saved: entry.saved,
|
|
32800
|
+
lastError,
|
|
32801
|
+
attempts: attemptNo
|
|
32802
|
+
});
|
|
32803
|
+
}
|
|
32804
|
+
});
|
|
32805
|
+
return next;
|
|
32806
|
+
}
|
|
32807
|
+
};
|
|
32808
|
+
/**
|
|
32638
32809
|
* Convert an IDevice to the flat DeviceSummary shape expected by the
|
|
32639
32810
|
* device-provider cap router. Shared across all providers.
|
|
32640
32811
|
*/
|
|
@@ -32683,6 +32854,7 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32683
32854
|
}];
|
|
32684
32855
|
}
|
|
32685
32856
|
async onShutdown() {
|
|
32857
|
+
this.cancelRestoreRetries();
|
|
32686
32858
|
const devices = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
32687
32859
|
for (const device of devices) try {
|
|
32688
32860
|
await this.ctx.kernel.devices?.decommission(device.id);
|
|
@@ -32700,9 +32872,16 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32700
32872
|
async start() {}
|
|
32701
32873
|
async stop() {}
|
|
32702
32874
|
async getStatus() {
|
|
32875
|
+
const all = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
32876
|
+
const summary = this.restoreFailureSummary();
|
|
32877
|
+
if (summary === null) return {
|
|
32878
|
+
connected: true,
|
|
32879
|
+
deviceCount: all.length
|
|
32880
|
+
};
|
|
32703
32881
|
return {
|
|
32704
32882
|
connected: true,
|
|
32705
|
-
deviceCount:
|
|
32883
|
+
deviceCount: all.length,
|
|
32884
|
+
error: summary
|
|
32706
32885
|
};
|
|
32707
32886
|
}
|
|
32708
32887
|
async getDevices() {
|
|
@@ -32792,8 +32971,137 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32792
32971
|
};
|
|
32793
32972
|
}
|
|
32794
32973
|
async restoreDevices(savedDevices) {
|
|
32795
|
-
await this.onRestoreDevices(savedDevices);
|
|
32796
|
-
if (savedDevices.length
|
|
32974
|
+
const report = await this.onRestoreDevices(savedDevices);
|
|
32975
|
+
if (savedDevices.length === 0) return;
|
|
32976
|
+
if (report && report.failedCount > 0) {
|
|
32977
|
+
this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
|
|
32978
|
+
return;
|
|
32979
|
+
}
|
|
32980
|
+
const restoredCount = report ? report.restoredCount : savedDevices.length;
|
|
32981
|
+
this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
|
|
32982
|
+
}
|
|
32983
|
+
/** Retry schedule. Overridable (tests use millisecond delays). */
|
|
32984
|
+
restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
32985
|
+
/** Retry lane width. See `device-restore-retry.ts` for why retries
|
|
32986
|
+
* never re-stampede full-width while the initial pass does (D167). */
|
|
32987
|
+
restoreRetryConcurrency = 4;
|
|
32988
|
+
_restoreRetryScheduler = null;
|
|
32989
|
+
_restoreRetryCompletion = null;
|
|
32990
|
+
_permanentRestoreFailures = /* @__PURE__ */ new Map();
|
|
32991
|
+
/** Settles when the background retry rounds finish (or `null` when
|
|
32992
|
+
* nothing failed). Exposed for tests and subclass diagnostics —
|
|
32993
|
+
* boot NEVER awaits this: the runner's post-init handshake goes out
|
|
32994
|
+
* with the devices that restored, and a late success is announced
|
|
32995
|
+
* through the `native-cap-change` → `updateCaps` path. */
|
|
32996
|
+
get restoreRetryCompletion() {
|
|
32997
|
+
return this._restoreRetryCompletion;
|
|
32998
|
+
}
|
|
32999
|
+
/** Devices that exhausted the retry bound this process lifetime. */
|
|
33000
|
+
get permanentRestoreFailures() {
|
|
33001
|
+
return [...this._permanentRestoreFailures.values()];
|
|
33002
|
+
}
|
|
33003
|
+
/** One-line operator-facing summary for `getStatus().error`, or
|
|
33004
|
+
* `null` when every device restored. */
|
|
33005
|
+
restoreFailureSummary() {
|
|
33006
|
+
if (this._permanentRestoreFailures.size === 0) return null;
|
|
33007
|
+
const ids = [...this._permanentRestoreFailures.keys()].join(", ");
|
|
33008
|
+
return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
|
|
33009
|
+
}
|
|
33010
|
+
cancelRestoreRetries() {
|
|
33011
|
+
this._restoreRetryScheduler?.cancel();
|
|
33012
|
+
this._restoreRetryScheduler = null;
|
|
33013
|
+
}
|
|
33014
|
+
recordPermanentRestoreFailure(failure) {
|
|
33015
|
+
this._permanentRestoreFailures.set(failure.deviceId, failure);
|
|
33016
|
+
this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
|
|
33017
|
+
tags: {
|
|
33018
|
+
deviceId: failure.deviceId,
|
|
33019
|
+
stableId: failure.stableId
|
|
33020
|
+
},
|
|
33021
|
+
meta: {
|
|
33022
|
+
type: failure.type,
|
|
33023
|
+
attempts: failure.attempts,
|
|
33024
|
+
error: failure.lastError
|
|
33025
|
+
}
|
|
33026
|
+
});
|
|
33027
|
+
}
|
|
33028
|
+
scheduleRestoreRetries(failures, attempt) {
|
|
33029
|
+
const scheduler = new DeviceRestoreRetryScheduler({
|
|
33030
|
+
logger: this.ctx.logger,
|
|
33031
|
+
delaysMs: this.restoreRetryDelaysMs,
|
|
33032
|
+
concurrency: this.restoreRetryConcurrency,
|
|
33033
|
+
attempt,
|
|
33034
|
+
onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
|
|
33035
|
+
});
|
|
33036
|
+
this._restoreRetryScheduler = scheduler;
|
|
33037
|
+
this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
|
|
33038
|
+
this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
33039
|
+
});
|
|
33040
|
+
}
|
|
33041
|
+
/**
|
|
33042
|
+
* Tear down and reconstruct ONE device from its persisted rows — the
|
|
33043
|
+
* `deviceProvider.reloadDevice` cap method. Persistence is never touched,
|
|
33044
|
+
* and no other device this provider owns is disturbed.
|
|
33045
|
+
*
|
|
33046
|
+
* Keyed by `stableId` because the caller's whole reason to be here is that
|
|
33047
|
+
* the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
|
|
33048
|
+
* fresh instance resolves its id through `allocateDeviceId`, which returns
|
|
33049
|
+
* whatever number the row carries NOW. The teardown is `decommission` —
|
|
33050
|
+
* exactly what a graceful shutdown runs per device (fires `removeDevice()`,
|
|
33051
|
+
* unregisters native caps, drops the registry entry) — and the rebuild is
|
|
33052
|
+
* the boot restore's own `create()` path, including its pass 2: first-class
|
|
33053
|
+
* children (hub-adopted cameras under an NVR) are decommissioned with the
|
|
33054
|
+
* parent by the cascade and must be re-created explicitly, because only
|
|
33055
|
+
* accessory children come back through `getAccessoryChildren()`.
|
|
33056
|
+
*
|
|
33057
|
+
* Reloading an accessory child directly is refused (no device class) —
|
|
33058
|
+
* reload its parent instead.
|
|
33059
|
+
*/
|
|
33060
|
+
async reloadDevice(input) {
|
|
33061
|
+
const { stableId } = input;
|
|
33062
|
+
const devices = this.ctx.kernel.devices;
|
|
33063
|
+
if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
|
|
33064
|
+
const live = (await devices.getAll()).find((d) => d.stableId === stableId);
|
|
33065
|
+
if (live) await devices.decommission(live.id);
|
|
33066
|
+
const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
|
|
33067
|
+
addonId: this.addonId,
|
|
33068
|
+
stableId
|
|
33069
|
+
});
|
|
33070
|
+
const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
|
|
33071
|
+
if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
|
|
33072
|
+
const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
|
|
33073
|
+
const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
|
|
33074
|
+
if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
|
|
33075
|
+
await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
|
|
33076
|
+
const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
|
|
33077
|
+
for (const row of rows) {
|
|
33078
|
+
if (row.parentDeviceId !== id) continue;
|
|
33079
|
+
const childType = Object.values(DeviceType).find((t) => t === row.type);
|
|
33080
|
+
const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
|
|
33081
|
+
if (!ChildClass) continue;
|
|
33082
|
+
try {
|
|
33083
|
+
await devices.create(row.stableId, ChildClass, {}, id);
|
|
33084
|
+
} catch (err) {
|
|
33085
|
+
this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
|
|
33086
|
+
tags: {
|
|
33087
|
+
deviceId: row.id,
|
|
33088
|
+
stableId: row.stableId
|
|
33089
|
+
},
|
|
33090
|
+
meta: {
|
|
33091
|
+
parentDeviceId: id,
|
|
33092
|
+
error: err instanceof Error ? err.message : String(err)
|
|
33093
|
+
}
|
|
33094
|
+
});
|
|
33095
|
+
}
|
|
33096
|
+
}
|
|
33097
|
+
this.ctx.logger.info("device reloaded in place from persisted rows", {
|
|
33098
|
+
tags: { deviceId: id },
|
|
33099
|
+
meta: {
|
|
33100
|
+
stableId,
|
|
33101
|
+
type: meta.type
|
|
33102
|
+
}
|
|
33103
|
+
});
|
|
33104
|
+
return { deviceId: id };
|
|
32797
33105
|
}
|
|
32798
33106
|
/**
|
|
32799
33107
|
* Restore devices from persisted state. Two-pass:
|
|
@@ -32819,55 +33127,108 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
32819
33127
|
* accessory-spawn flow handles via the parent's
|
|
32820
33128
|
* `getAccessoryChildren()`. Override only when the default doesn't
|
|
32821
33129
|
* fit.
|
|
33130
|
+
*
|
|
33131
|
+
* A row that fails either pass is NOT terminal (D347): it is handed
|
|
33132
|
+
* to a bounded background retry (`DeviceRestoreRetryScheduler`).
|
|
33133
|
+
* Only after the bound is exhausted is the device marked permanently
|
|
33134
|
+
* failed — logged at ERROR with `tags.deviceId` and surfaced via
|
|
33135
|
+
* `getStatus().error`.
|
|
32822
33136
|
*/
|
|
32823
33137
|
async onRestoreDevices(savedDevices) {
|
|
32824
33138
|
const restored = /* @__PURE__ */ new Set();
|
|
33139
|
+
const failures = [];
|
|
33140
|
+
const attemptRestore = async (saved) => {
|
|
33141
|
+
if (restored.has(saved.id)) return;
|
|
33142
|
+
const Class = this.deviceClasses[saved.type];
|
|
33143
|
+
if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
|
|
33144
|
+
if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
|
|
33145
|
+
await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
|
|
33146
|
+
restored.add(saved.id);
|
|
33147
|
+
};
|
|
32825
33148
|
const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
|
|
32826
33149
|
const restoreOne = async (saved) => {
|
|
32827
|
-
|
|
32828
|
-
if (!Class) {
|
|
33150
|
+
if (!this.deviceClasses[saved.type]) {
|
|
32829
33151
|
this.ctx.logger.warn("No device class registered for restored type — skipping", {
|
|
32830
|
-
tags: {
|
|
33152
|
+
tags: {
|
|
33153
|
+
deviceId: saved.id,
|
|
33154
|
+
stableId: saved.stableId
|
|
33155
|
+
},
|
|
32831
33156
|
meta: { type: saved.type }
|
|
32832
33157
|
});
|
|
32833
33158
|
return;
|
|
32834
33159
|
}
|
|
32835
33160
|
try {
|
|
32836
|
-
await
|
|
32837
|
-
restored.add(saved.id);
|
|
33161
|
+
await attemptRestore(saved);
|
|
32838
33162
|
} catch (err) {
|
|
32839
|
-
|
|
32840
|
-
|
|
33163
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
33164
|
+
this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
|
|
33165
|
+
tags: {
|
|
33166
|
+
deviceId: saved.id,
|
|
33167
|
+
stableId: saved.stableId
|
|
33168
|
+
},
|
|
32841
33169
|
meta: {
|
|
32842
33170
|
type: saved.type,
|
|
32843
|
-
|
|
33171
|
+
attempt: 1,
|
|
33172
|
+
error
|
|
32844
33173
|
}
|
|
32845
33174
|
});
|
|
33175
|
+
failures.push({
|
|
33176
|
+
saved,
|
|
33177
|
+
error
|
|
33178
|
+
});
|
|
32846
33179
|
}
|
|
32847
33180
|
};
|
|
32848
33181
|
await Promise.all(topLevel.map((saved) => restoreOne(saved)));
|
|
33182
|
+
const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
|
|
32849
33183
|
const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
|
|
32850
33184
|
for (const saved of childRows) {
|
|
32851
|
-
|
|
32852
|
-
if (!Class) continue;
|
|
33185
|
+
if (!this.deviceClasses[saved.type]) continue;
|
|
32853
33186
|
if (saved.parentDeviceId === null) continue;
|
|
32854
|
-
if (
|
|
32855
|
-
|
|
32856
|
-
|
|
32857
|
-
|
|
32858
|
-
|
|
32859
|
-
|
|
33187
|
+
if (restored.has(saved.parentDeviceId)) {
|
|
33188
|
+
try {
|
|
33189
|
+
await attemptRestore(saved);
|
|
33190
|
+
} catch (err) {
|
|
33191
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
33192
|
+
this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
|
|
33193
|
+
tags: {
|
|
33194
|
+
deviceId: saved.id,
|
|
33195
|
+
stableId: saved.stableId,
|
|
33196
|
+
parentDeviceId: saved.parentDeviceId
|
|
33197
|
+
},
|
|
33198
|
+
meta: {
|
|
33199
|
+
type: saved.type,
|
|
33200
|
+
attempt: 1,
|
|
33201
|
+
error
|
|
33202
|
+
}
|
|
33203
|
+
});
|
|
33204
|
+
failures.push({
|
|
33205
|
+
saved,
|
|
33206
|
+
error
|
|
33207
|
+
});
|
|
33208
|
+
}
|
|
33209
|
+
continue;
|
|
33210
|
+
}
|
|
33211
|
+
if (failedTopLevelIds.has(saved.parentDeviceId)) {
|
|
33212
|
+
this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
|
|
32860
33213
|
tags: {
|
|
33214
|
+
deviceId: saved.id,
|
|
32861
33215
|
stableId: saved.stableId,
|
|
32862
33216
|
parentDeviceId: saved.parentDeviceId
|
|
32863
33217
|
},
|
|
32864
|
-
meta: {
|
|
32865
|
-
type: saved.type,
|
|
32866
|
-
error: err instanceof Error ? err.message : String(err)
|
|
32867
|
-
}
|
|
33218
|
+
meta: { type: saved.type }
|
|
32868
33219
|
});
|
|
33220
|
+
failures.push({
|
|
33221
|
+
saved,
|
|
33222
|
+
error: `parent device ${saved.parentDeviceId} not restored`
|
|
33223
|
+
});
|
|
33224
|
+
continue;
|
|
32869
33225
|
}
|
|
32870
33226
|
}
|
|
33227
|
+
if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
|
|
33228
|
+
return {
|
|
33229
|
+
restoredCount: restored.size,
|
|
33230
|
+
failedCount: failures.length
|
|
33231
|
+
};
|
|
32871
33232
|
}
|
|
32872
33233
|
/** Convert an IDevice to the flat DeviceSummary for the cap router. */
|
|
32873
33234
|
toSummary(device) {
|
|
@@ -34630,6 +34991,12 @@ Object.freeze({
|
|
|
34630
34991
|
addonId: null,
|
|
34631
34992
|
access: "view"
|
|
34632
34993
|
},
|
|
34994
|
+
"deviceProvider.reloadDevice": {
|
|
34995
|
+
capName: "device-provider",
|
|
34996
|
+
capScope: "system",
|
|
34997
|
+
addonId: null,
|
|
34998
|
+
access: "create"
|
|
34999
|
+
},
|
|
34633
35000
|
"deviceProvider.start": {
|
|
34634
35001
|
capName: "device-provider",
|
|
34635
35002
|
capScope: "system",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-provider-wyze",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.64",
|
|
4
4
|
"description": "Wyze camera device-provider addon for CamStack — wraps the @apocaliss92/wyze-bridge-js P2P/DTLS client, feeding the stream-broker via the pull-rfc4571 lazy-publish path (a structural twin of addon-provider-reolink)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|