@camstack/addon-provider-reolink 1.2.82 → 1.2.83
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/DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs +2 -0
- package/dist/addon.js +1182 -123
- package/dist/addon.mjs +1184 -126
- package/dist/index.js +1 -0
- package/dist/index.mjs +2 -2
- package/package.json +1 -1
- package/dist/DiagnosticsTools-QJ3CRYGA-9NV95vRN.mjs +0 -2
package/dist/addon.js
CHANGED
|
@@ -7199,7 +7199,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7199
7199
|
* still gives the event loop a chance to drain — useful for breaking
|
|
7200
7200
|
* up tight async loops without changing call-site semantics.
|
|
7201
7201
|
*/
|
|
7202
|
-
function sleep$
|
|
7202
|
+
function sleep$2(ms) {
|
|
7203
7203
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
7204
7204
|
}
|
|
7205
7205
|
var EncodeProfileSchema = object({
|
|
@@ -12411,6 +12411,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
12411
12411
|
filePath: string(),
|
|
12412
12412
|
content: string()
|
|
12413
12413
|
})) }), { auth: "admin" });
|
|
12414
|
+
/**
|
|
12415
|
+
* Identity — preserves literal types for downstream inference.
|
|
12416
|
+
*
|
|
12417
|
+
* The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
|
|
12418
|
+
* TypeScript does not widen each entry's literal `kind`/`auth` fields to
|
|
12419
|
+
* the broader unions declared on `CustomActionSpec`'s default generics.
|
|
12420
|
+
* Shape validity is enforced separately by the `customAction(...)` helper
|
|
12421
|
+
* whose return type is already a `CustomActionSpec<...>`.
|
|
12422
|
+
*/
|
|
12423
|
+
function defineCustomActions(spec) {
|
|
12424
|
+
return spec;
|
|
12425
|
+
}
|
|
12426
|
+
function customAction(input, output, options) {
|
|
12427
|
+
return {
|
|
12428
|
+
input,
|
|
12429
|
+
output,
|
|
12430
|
+
kind: options?.kind ?? "query",
|
|
12431
|
+
auth: options?.auth ?? "protected",
|
|
12432
|
+
scope: options?.scope ?? { kind: "system" },
|
|
12433
|
+
...options?.caller ? { caller: "required" } : {}
|
|
12434
|
+
};
|
|
12435
|
+
}
|
|
12414
12436
|
function deviceCustomAction(input, output, options) {
|
|
12415
12437
|
return {
|
|
12416
12438
|
input,
|
|
@@ -13395,6 +13417,35 @@ var deviceProviderCapability = {
|
|
|
13395
13417
|
name: string(),
|
|
13396
13418
|
type: string()
|
|
13397
13419
|
}))),
|
|
13420
|
+
/**
|
|
13421
|
+
* Tear down and reconstruct ONE device in place from its persisted rows —
|
|
13422
|
+
* touching no other device this provider owns.
|
|
13423
|
+
*
|
|
13424
|
+
* The primitive `deviceManager.migrateDevice` uses to flush the two
|
|
13425
|
+
* migrated numbers: after `swapIds` the runner's live instance still
|
|
13426
|
+
* carries the PRE-swap numeric id (baked into the object, its native-cap
|
|
13427
|
+
* registrations and its log tags), and a live object cannot be renumbered.
|
|
13428
|
+
* Before this method the only flush was restarting the whole owning addon
|
|
13429
|
+
* — which took every camera the provider owns down with it (28 devices
|
|
13430
|
+
* for one migrated camera, measured 2026-09-04, and the morning of the
|
|
13431
|
+
* same day ~27 devices' native caps did not come back on their own).
|
|
13432
|
+
*
|
|
13433
|
+
* Keyed by `stableId`, deliberately: the numeric id is exactly the thing
|
|
13434
|
+
* that changes. The reply carries the id the device answers on NOW.
|
|
13435
|
+
* Implemented once in `BaseDeviceProvider` — decommission the live
|
|
13436
|
+
* instance (if any), then re-create from the persisted row: the same
|
|
13437
|
+
* teardown/rehydrate pair every graceful shutdown + boot already uses.
|
|
13438
|
+
* An RPC, never an event: a dropped event would leave the runner writing
|
|
13439
|
+
* against the wrong camera (D8).
|
|
13440
|
+
*
|
|
13441
|
+
* Construction can dial hardware, and the migrated source is
|
|
13442
|
+
* characteristically dead — the timeout covers a full activate window
|
|
13443
|
+
* rather than the 60 s default.
|
|
13444
|
+
*/
|
|
13445
|
+
reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
|
|
13446
|
+
kind: "mutation",
|
|
13447
|
+
timeoutMs: 3 * 6e4
|
|
13448
|
+
}),
|
|
13398
13449
|
supportsDiscovery: method(object({}), boolean()),
|
|
13399
13450
|
/**
|
|
13400
13451
|
* Run a network scan. `params` carries optional provider-specific scan
|
|
@@ -13722,7 +13773,8 @@ method(object({
|
|
|
13722
13773
|
targetId: number()
|
|
13723
13774
|
}), MigrateDeviceResultSchema, {
|
|
13724
13775
|
kind: "mutation",
|
|
13725
|
-
auth: "admin"
|
|
13776
|
+
auth: "admin",
|
|
13777
|
+
timeoutMs: 12 * 6e4
|
|
13726
13778
|
}), 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({
|
|
13727
13779
|
deviceId: number(),
|
|
13728
13780
|
name: string()
|
|
@@ -33522,6 +33574,147 @@ var BaseDevice = class {
|
|
|
33522
33574
|
}
|
|
33523
33575
|
};
|
|
33524
33576
|
/**
|
|
33577
|
+
* Delays before retry rounds 1..N — the round count IS the bound.
|
|
33578
|
+
* 10 s catches "the hub was busy for a moment"; the full schedule
|
|
33579
|
+
* (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
|
|
33580
|
+
* per attempt) covers a device-manager lock held for minutes — the
|
|
33581
|
+
* 2026-09-04 outage's migration hold was ~3.5 min.
|
|
33582
|
+
*/
|
|
33583
|
+
var DEVICE_RESTORE_RETRY_DELAYS_MS = [
|
|
33584
|
+
1e4,
|
|
33585
|
+
3e4,
|
|
33586
|
+
9e4
|
|
33587
|
+
];
|
|
33588
|
+
/** Abortable sleep — resolves early (never rejects) on abort. */
|
|
33589
|
+
function sleep$1(ms, signal) {
|
|
33590
|
+
return new Promise((resolve) => {
|
|
33591
|
+
if (signal.aborted) {
|
|
33592
|
+
resolve();
|
|
33593
|
+
return;
|
|
33594
|
+
}
|
|
33595
|
+
const onAbort = () => {
|
|
33596
|
+
clearTimeout(timer);
|
|
33597
|
+
resolve();
|
|
33598
|
+
};
|
|
33599
|
+
const timer = setTimeout(() => {
|
|
33600
|
+
signal.removeEventListener("abort", onAbort);
|
|
33601
|
+
resolve();
|
|
33602
|
+
}, ms);
|
|
33603
|
+
timer.unref?.();
|
|
33604
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
33605
|
+
});
|
|
33606
|
+
}
|
|
33607
|
+
/** Drain `items` through at most `width` concurrent lanes. `fn` must
|
|
33608
|
+
* not reject (callers wrap their own try/catch). */
|
|
33609
|
+
async function runWithConcurrency(items, width, fn) {
|
|
33610
|
+
const queue = [...items];
|
|
33611
|
+
const laneCount = Math.max(1, Math.min(width, queue.length));
|
|
33612
|
+
const lane = async () => {
|
|
33613
|
+
for (;;) {
|
|
33614
|
+
const item = queue.shift();
|
|
33615
|
+
if (item === void 0) return;
|
|
33616
|
+
await fn(item);
|
|
33617
|
+
}
|
|
33618
|
+
};
|
|
33619
|
+
await Promise.all(Array.from({ length: laneCount }, lane));
|
|
33620
|
+
}
|
|
33621
|
+
var DeviceRestoreRetryScheduler = class {
|
|
33622
|
+
#logger;
|
|
33623
|
+
#attempt;
|
|
33624
|
+
#onPermanentFailure;
|
|
33625
|
+
#delaysMs;
|
|
33626
|
+
#concurrency;
|
|
33627
|
+
#now;
|
|
33628
|
+
#abort = new AbortController();
|
|
33629
|
+
constructor(options) {
|
|
33630
|
+
this.#logger = options.logger;
|
|
33631
|
+
this.#attempt = options.attempt;
|
|
33632
|
+
this.#onPermanentFailure = options.onPermanentFailure;
|
|
33633
|
+
this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
33634
|
+
this.#concurrency = options.concurrency ?? 4;
|
|
33635
|
+
this.#now = options.now ?? Date.now;
|
|
33636
|
+
}
|
|
33637
|
+
/** Stop retrying (shutdown). Pending entries are NOT marked
|
|
33638
|
+
* permanently failed — the next boot restores them from disk. */
|
|
33639
|
+
cancel() {
|
|
33640
|
+
this.#abort.abort();
|
|
33641
|
+
}
|
|
33642
|
+
/**
|
|
33643
|
+
* Run the bounded retry rounds. Resolves when every entry has either
|
|
33644
|
+
* restored, been marked permanently failed, or the scheduler was
|
|
33645
|
+
* cancelled. Never rejects.
|
|
33646
|
+
*/
|
|
33647
|
+
async run(initialFailures) {
|
|
33648
|
+
let pending = initialFailures.map((failure) => ({
|
|
33649
|
+
saved: failure.saved,
|
|
33650
|
+
lastError: failure.error,
|
|
33651
|
+
attempts: 1
|
|
33652
|
+
}));
|
|
33653
|
+
for (let round = 0; round < this.#delaysMs.length; round += 1) {
|
|
33654
|
+
if (pending.length === 0 || this.#abort.signal.aborted) break;
|
|
33655
|
+
await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
|
|
33656
|
+
if (this.#abort.signal.aborted) break;
|
|
33657
|
+
pending = await this.#runRound(pending, round);
|
|
33658
|
+
}
|
|
33659
|
+
if (this.#abort.signal.aborted) return [];
|
|
33660
|
+
const terminal = pending.map((entry) => ({
|
|
33661
|
+
deviceId: entry.saved.id,
|
|
33662
|
+
stableId: entry.saved.stableId,
|
|
33663
|
+
type: String(entry.saved.type),
|
|
33664
|
+
attempts: entry.attempts,
|
|
33665
|
+
lastError: entry.lastError,
|
|
33666
|
+
failedAt: this.#now()
|
|
33667
|
+
}));
|
|
33668
|
+
for (const failure of terminal) this.#onPermanentFailure(failure);
|
|
33669
|
+
return terminal;
|
|
33670
|
+
}
|
|
33671
|
+
/** One retry round: parents first (phase 0), then hub-adopted
|
|
33672
|
+
* children (phase 1) — a child's attempt depends on its parent
|
|
33673
|
+
* having landed, exactly like the initial two-pass restore. */
|
|
33674
|
+
async #runRound(pending, round) {
|
|
33675
|
+
const next = [];
|
|
33676
|
+
const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
|
|
33677
|
+
const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
|
|
33678
|
+
for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
|
|
33679
|
+
if (this.#abort.signal.aborted) {
|
|
33680
|
+
next.push(entry);
|
|
33681
|
+
return;
|
|
33682
|
+
}
|
|
33683
|
+
const attemptNo = entry.attempts + 1;
|
|
33684
|
+
try {
|
|
33685
|
+
await this.#attempt(entry.saved);
|
|
33686
|
+
this.#logger.info("Device restored on retry", {
|
|
33687
|
+
tags: {
|
|
33688
|
+
deviceId: entry.saved.id,
|
|
33689
|
+
stableId: entry.saved.stableId
|
|
33690
|
+
},
|
|
33691
|
+
meta: { attempt: attemptNo }
|
|
33692
|
+
});
|
|
33693
|
+
} catch (err) {
|
|
33694
|
+
const lastError = err instanceof Error ? err.message : String(err);
|
|
33695
|
+
const remainingRetries = this.#delaysMs.length - (round + 1);
|
|
33696
|
+
this.#logger.warn("Device restore retry failed", {
|
|
33697
|
+
tags: {
|
|
33698
|
+
deviceId: entry.saved.id,
|
|
33699
|
+
stableId: entry.saved.stableId
|
|
33700
|
+
},
|
|
33701
|
+
meta: {
|
|
33702
|
+
attempt: attemptNo,
|
|
33703
|
+
remainingRetries,
|
|
33704
|
+
error: lastError
|
|
33705
|
+
}
|
|
33706
|
+
});
|
|
33707
|
+
next.push({
|
|
33708
|
+
saved: entry.saved,
|
|
33709
|
+
lastError,
|
|
33710
|
+
attempts: attemptNo
|
|
33711
|
+
});
|
|
33712
|
+
}
|
|
33713
|
+
});
|
|
33714
|
+
return next;
|
|
33715
|
+
}
|
|
33716
|
+
};
|
|
33717
|
+
/**
|
|
33525
33718
|
* Convert an IDevice to the flat DeviceSummary shape expected by the
|
|
33526
33719
|
* device-provider cap router. Shared across all providers.
|
|
33527
33720
|
*/
|
|
@@ -33570,6 +33763,7 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33570
33763
|
}];
|
|
33571
33764
|
}
|
|
33572
33765
|
async onShutdown() {
|
|
33766
|
+
this.cancelRestoreRetries();
|
|
33573
33767
|
const devices = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
33574
33768
|
for (const device of devices) try {
|
|
33575
33769
|
await this.ctx.kernel.devices?.decommission(device.id);
|
|
@@ -33587,9 +33781,16 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33587
33781
|
async start() {}
|
|
33588
33782
|
async stop() {}
|
|
33589
33783
|
async getStatus() {
|
|
33784
|
+
const all = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
33785
|
+
const summary = this.restoreFailureSummary();
|
|
33786
|
+
if (summary === null) return {
|
|
33787
|
+
connected: true,
|
|
33788
|
+
deviceCount: all.length
|
|
33789
|
+
};
|
|
33590
33790
|
return {
|
|
33591
33791
|
connected: true,
|
|
33592
|
-
deviceCount:
|
|
33792
|
+
deviceCount: all.length,
|
|
33793
|
+
error: summary
|
|
33593
33794
|
};
|
|
33594
33795
|
}
|
|
33595
33796
|
async getDevices() {
|
|
@@ -33679,8 +33880,137 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33679
33880
|
};
|
|
33680
33881
|
}
|
|
33681
33882
|
async restoreDevices(savedDevices) {
|
|
33682
|
-
await this.onRestoreDevices(savedDevices);
|
|
33683
|
-
if (savedDevices.length
|
|
33883
|
+
const report = await this.onRestoreDevices(savedDevices);
|
|
33884
|
+
if (savedDevices.length === 0) return;
|
|
33885
|
+
if (report && report.failedCount > 0) {
|
|
33886
|
+
this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
|
|
33887
|
+
return;
|
|
33888
|
+
}
|
|
33889
|
+
const restoredCount = report ? report.restoredCount : savedDevices.length;
|
|
33890
|
+
this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
|
|
33891
|
+
}
|
|
33892
|
+
/** Retry schedule. Overridable (tests use millisecond delays). */
|
|
33893
|
+
restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
33894
|
+
/** Retry lane width. See `device-restore-retry.ts` for why retries
|
|
33895
|
+
* never re-stampede full-width while the initial pass does (D167). */
|
|
33896
|
+
restoreRetryConcurrency = 4;
|
|
33897
|
+
_restoreRetryScheduler = null;
|
|
33898
|
+
_restoreRetryCompletion = null;
|
|
33899
|
+
_permanentRestoreFailures = /* @__PURE__ */ new Map();
|
|
33900
|
+
/** Settles when the background retry rounds finish (or `null` when
|
|
33901
|
+
* nothing failed). Exposed for tests and subclass diagnostics —
|
|
33902
|
+
* boot NEVER awaits this: the runner's post-init handshake goes out
|
|
33903
|
+
* with the devices that restored, and a late success is announced
|
|
33904
|
+
* through the `native-cap-change` → `updateCaps` path. */
|
|
33905
|
+
get restoreRetryCompletion() {
|
|
33906
|
+
return this._restoreRetryCompletion;
|
|
33907
|
+
}
|
|
33908
|
+
/** Devices that exhausted the retry bound this process lifetime. */
|
|
33909
|
+
get permanentRestoreFailures() {
|
|
33910
|
+
return [...this._permanentRestoreFailures.values()];
|
|
33911
|
+
}
|
|
33912
|
+
/** One-line operator-facing summary for `getStatus().error`, or
|
|
33913
|
+
* `null` when every device restored. */
|
|
33914
|
+
restoreFailureSummary() {
|
|
33915
|
+
if (this._permanentRestoreFailures.size === 0) return null;
|
|
33916
|
+
const ids = [...this._permanentRestoreFailures.keys()].join(", ");
|
|
33917
|
+
return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
|
|
33918
|
+
}
|
|
33919
|
+
cancelRestoreRetries() {
|
|
33920
|
+
this._restoreRetryScheduler?.cancel();
|
|
33921
|
+
this._restoreRetryScheduler = null;
|
|
33922
|
+
}
|
|
33923
|
+
recordPermanentRestoreFailure(failure) {
|
|
33924
|
+
this._permanentRestoreFailures.set(failure.deviceId, failure);
|
|
33925
|
+
this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
|
|
33926
|
+
tags: {
|
|
33927
|
+
deviceId: failure.deviceId,
|
|
33928
|
+
stableId: failure.stableId
|
|
33929
|
+
},
|
|
33930
|
+
meta: {
|
|
33931
|
+
type: failure.type,
|
|
33932
|
+
attempts: failure.attempts,
|
|
33933
|
+
error: failure.lastError
|
|
33934
|
+
}
|
|
33935
|
+
});
|
|
33936
|
+
}
|
|
33937
|
+
scheduleRestoreRetries(failures, attempt) {
|
|
33938
|
+
const scheduler = new DeviceRestoreRetryScheduler({
|
|
33939
|
+
logger: this.ctx.logger,
|
|
33940
|
+
delaysMs: this.restoreRetryDelaysMs,
|
|
33941
|
+
concurrency: this.restoreRetryConcurrency,
|
|
33942
|
+
attempt,
|
|
33943
|
+
onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
|
|
33944
|
+
});
|
|
33945
|
+
this._restoreRetryScheduler = scheduler;
|
|
33946
|
+
this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
|
|
33947
|
+
this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
33948
|
+
});
|
|
33949
|
+
}
|
|
33950
|
+
/**
|
|
33951
|
+
* Tear down and reconstruct ONE device from its persisted rows — the
|
|
33952
|
+
* `deviceProvider.reloadDevice` cap method. Persistence is never touched,
|
|
33953
|
+
* and no other device this provider owns is disturbed.
|
|
33954
|
+
*
|
|
33955
|
+
* Keyed by `stableId` because the caller's whole reason to be here is that
|
|
33956
|
+
* the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
|
|
33957
|
+
* fresh instance resolves its id through `allocateDeviceId`, which returns
|
|
33958
|
+
* whatever number the row carries NOW. The teardown is `decommission` —
|
|
33959
|
+
* exactly what a graceful shutdown runs per device (fires `removeDevice()`,
|
|
33960
|
+
* unregisters native caps, drops the registry entry) — and the rebuild is
|
|
33961
|
+
* the boot restore's own `create()` path, including its pass 2: first-class
|
|
33962
|
+
* children (hub-adopted cameras under an NVR) are decommissioned with the
|
|
33963
|
+
* parent by the cascade and must be re-created explicitly, because only
|
|
33964
|
+
* accessory children come back through `getAccessoryChildren()`.
|
|
33965
|
+
*
|
|
33966
|
+
* Reloading an accessory child directly is refused (no device class) —
|
|
33967
|
+
* reload its parent instead.
|
|
33968
|
+
*/
|
|
33969
|
+
async reloadDevice(input) {
|
|
33970
|
+
const { stableId } = input;
|
|
33971
|
+
const devices = this.ctx.kernel.devices;
|
|
33972
|
+
if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
|
|
33973
|
+
const live = (await devices.getAll()).find((d) => d.stableId === stableId);
|
|
33974
|
+
if (live) await devices.decommission(live.id);
|
|
33975
|
+
const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
|
|
33976
|
+
addonId: this.addonId,
|
|
33977
|
+
stableId
|
|
33978
|
+
});
|
|
33979
|
+
const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
|
|
33980
|
+
if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
|
|
33981
|
+
const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
|
|
33982
|
+
const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
|
|
33983
|
+
if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
|
|
33984
|
+
await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
|
|
33985
|
+
const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
|
|
33986
|
+
for (const row of rows) {
|
|
33987
|
+
if (row.parentDeviceId !== id) continue;
|
|
33988
|
+
const childType = Object.values(DeviceType).find((t) => t === row.type);
|
|
33989
|
+
const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
|
|
33990
|
+
if (!ChildClass) continue;
|
|
33991
|
+
try {
|
|
33992
|
+
await devices.create(row.stableId, ChildClass, {}, id);
|
|
33993
|
+
} catch (err) {
|
|
33994
|
+
this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
|
|
33995
|
+
tags: {
|
|
33996
|
+
deviceId: row.id,
|
|
33997
|
+
stableId: row.stableId
|
|
33998
|
+
},
|
|
33999
|
+
meta: {
|
|
34000
|
+
parentDeviceId: id,
|
|
34001
|
+
error: err instanceof Error ? err.message : String(err)
|
|
34002
|
+
}
|
|
34003
|
+
});
|
|
34004
|
+
}
|
|
34005
|
+
}
|
|
34006
|
+
this.ctx.logger.info("device reloaded in place from persisted rows", {
|
|
34007
|
+
tags: { deviceId: id },
|
|
34008
|
+
meta: {
|
|
34009
|
+
stableId,
|
|
34010
|
+
type: meta.type
|
|
34011
|
+
}
|
|
34012
|
+
});
|
|
34013
|
+
return { deviceId: id };
|
|
33684
34014
|
}
|
|
33685
34015
|
/**
|
|
33686
34016
|
* Restore devices from persisted state. Two-pass:
|
|
@@ -33706,55 +34036,108 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33706
34036
|
* accessory-spawn flow handles via the parent's
|
|
33707
34037
|
* `getAccessoryChildren()`. Override only when the default doesn't
|
|
33708
34038
|
* fit.
|
|
34039
|
+
*
|
|
34040
|
+
* A row that fails either pass is NOT terminal (D347): it is handed
|
|
34041
|
+
* to a bounded background retry (`DeviceRestoreRetryScheduler`).
|
|
34042
|
+
* Only after the bound is exhausted is the device marked permanently
|
|
34043
|
+
* failed — logged at ERROR with `tags.deviceId` and surfaced via
|
|
34044
|
+
* `getStatus().error`.
|
|
33709
34045
|
*/
|
|
33710
34046
|
async onRestoreDevices(savedDevices) {
|
|
33711
34047
|
const restored = /* @__PURE__ */ new Set();
|
|
34048
|
+
const failures = [];
|
|
34049
|
+
const attemptRestore = async (saved) => {
|
|
34050
|
+
if (restored.has(saved.id)) return;
|
|
34051
|
+
const Class = this.deviceClasses[saved.type];
|
|
34052
|
+
if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
|
|
34053
|
+
if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
|
|
34054
|
+
await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
|
|
34055
|
+
restored.add(saved.id);
|
|
34056
|
+
};
|
|
33712
34057
|
const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
|
|
33713
34058
|
const restoreOne = async (saved) => {
|
|
33714
|
-
|
|
33715
|
-
if (!Class) {
|
|
34059
|
+
if (!this.deviceClasses[saved.type]) {
|
|
33716
34060
|
this.ctx.logger.warn("No device class registered for restored type — skipping", {
|
|
33717
|
-
tags: {
|
|
34061
|
+
tags: {
|
|
34062
|
+
deviceId: saved.id,
|
|
34063
|
+
stableId: saved.stableId
|
|
34064
|
+
},
|
|
33718
34065
|
meta: { type: saved.type }
|
|
33719
34066
|
});
|
|
33720
34067
|
return;
|
|
33721
34068
|
}
|
|
33722
34069
|
try {
|
|
33723
|
-
await
|
|
33724
|
-
restored.add(saved.id);
|
|
34070
|
+
await attemptRestore(saved);
|
|
33725
34071
|
} catch (err) {
|
|
33726
|
-
|
|
33727
|
-
|
|
34072
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
34073
|
+
this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
|
|
34074
|
+
tags: {
|
|
34075
|
+
deviceId: saved.id,
|
|
34076
|
+
stableId: saved.stableId
|
|
34077
|
+
},
|
|
33728
34078
|
meta: {
|
|
33729
34079
|
type: saved.type,
|
|
33730
|
-
|
|
34080
|
+
attempt: 1,
|
|
34081
|
+
error
|
|
33731
34082
|
}
|
|
33732
34083
|
});
|
|
34084
|
+
failures.push({
|
|
34085
|
+
saved,
|
|
34086
|
+
error
|
|
34087
|
+
});
|
|
33733
34088
|
}
|
|
33734
34089
|
};
|
|
33735
34090
|
await Promise.all(topLevel.map((saved) => restoreOne(saved)));
|
|
34091
|
+
const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
|
|
33736
34092
|
const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
|
|
33737
34093
|
for (const saved of childRows) {
|
|
33738
|
-
|
|
33739
|
-
if (!Class) continue;
|
|
34094
|
+
if (!this.deviceClasses[saved.type]) continue;
|
|
33740
34095
|
if (saved.parentDeviceId === null) continue;
|
|
33741
|
-
if (
|
|
33742
|
-
|
|
33743
|
-
|
|
33744
|
-
|
|
33745
|
-
|
|
33746
|
-
|
|
34096
|
+
if (restored.has(saved.parentDeviceId)) {
|
|
34097
|
+
try {
|
|
34098
|
+
await attemptRestore(saved);
|
|
34099
|
+
} catch (err) {
|
|
34100
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
34101
|
+
this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
|
|
34102
|
+
tags: {
|
|
34103
|
+
deviceId: saved.id,
|
|
34104
|
+
stableId: saved.stableId,
|
|
34105
|
+
parentDeviceId: saved.parentDeviceId
|
|
34106
|
+
},
|
|
34107
|
+
meta: {
|
|
34108
|
+
type: saved.type,
|
|
34109
|
+
attempt: 1,
|
|
34110
|
+
error
|
|
34111
|
+
}
|
|
34112
|
+
});
|
|
34113
|
+
failures.push({
|
|
34114
|
+
saved,
|
|
34115
|
+
error
|
|
34116
|
+
});
|
|
34117
|
+
}
|
|
34118
|
+
continue;
|
|
34119
|
+
}
|
|
34120
|
+
if (failedTopLevelIds.has(saved.parentDeviceId)) {
|
|
34121
|
+
this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
|
|
33747
34122
|
tags: {
|
|
34123
|
+
deviceId: saved.id,
|
|
33748
34124
|
stableId: saved.stableId,
|
|
33749
34125
|
parentDeviceId: saved.parentDeviceId
|
|
33750
34126
|
},
|
|
33751
|
-
meta: {
|
|
33752
|
-
type: saved.type,
|
|
33753
|
-
error: err instanceof Error ? err.message : String(err)
|
|
33754
|
-
}
|
|
34127
|
+
meta: { type: saved.type }
|
|
33755
34128
|
});
|
|
34129
|
+
failures.push({
|
|
34130
|
+
saved,
|
|
34131
|
+
error: `parent device ${saved.parentDeviceId} not restored`
|
|
34132
|
+
});
|
|
34133
|
+
continue;
|
|
33756
34134
|
}
|
|
33757
34135
|
}
|
|
34136
|
+
if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
|
|
34137
|
+
return {
|
|
34138
|
+
restoredCount: restored.size,
|
|
34139
|
+
failedCount: failures.length
|
|
34140
|
+
};
|
|
33758
34141
|
}
|
|
33759
34142
|
/** Convert an IDevice to the flat DeviceSummary for the cap router. */
|
|
33760
34143
|
toSummary(device) {
|
|
@@ -35669,6 +36052,12 @@ Object.freeze({
|
|
|
35669
36052
|
addonId: null,
|
|
35670
36053
|
access: "view"
|
|
35671
36054
|
},
|
|
36055
|
+
"deviceProvider.reloadDevice": {
|
|
36056
|
+
capName: "device-provider",
|
|
36057
|
+
capScope: "system",
|
|
36058
|
+
addonId: null,
|
|
36059
|
+
access: "create"
|
|
36060
|
+
},
|
|
35672
36061
|
"deviceProvider.start": {
|
|
35673
36062
|
capName: "device-provider",
|
|
35674
36063
|
capScope: "system",
|
|
@@ -230692,6 +231081,453 @@ var IntercomFailureReport = class {
|
|
|
230692
231081
|
/** The process-wide instance every camera in this addon notes into. */
|
|
230693
231082
|
var intercomFailureReport = new IntercomFailureReport();
|
|
230694
231083
|
//#endregion
|
|
231084
|
+
//#region src/snapshot-freshness.ts
|
|
231085
|
+
/**
|
|
231086
|
+
* The snapshot groups the freshness panel reports on, with the Baichuan
|
|
231087
|
+
* read behind each. Order is the display order.
|
|
231088
|
+
*/
|
|
231089
|
+
var SNAPSHOT_GROUPS = [
|
|
231090
|
+
{
|
|
231091
|
+
key: "imageSnapshot",
|
|
231092
|
+
label: "Image (getVideoInput)"
|
|
231093
|
+
},
|
|
231094
|
+
{
|
|
231095
|
+
key: "motionSnapshot",
|
|
231096
|
+
label: "Motion (getMotionAlarm)"
|
|
231097
|
+
},
|
|
231098
|
+
{
|
|
231099
|
+
key: "aiSensitivitySnapshot",
|
|
231100
|
+
label: "AI sensitivity (getAiDetectionFull)"
|
|
231101
|
+
},
|
|
231102
|
+
{
|
|
231103
|
+
key: "encSnapshot",
|
|
231104
|
+
label: "Encoder (getEnc)"
|
|
231105
|
+
},
|
|
231106
|
+
{
|
|
231107
|
+
key: "encOptionsSnapshot",
|
|
231108
|
+
label: "Encoder options (getEncOptions)"
|
|
231109
|
+
},
|
|
231110
|
+
{
|
|
231111
|
+
key: "maskSnapshot",
|
|
231112
|
+
label: "Privacy mask (getMask)"
|
|
231113
|
+
},
|
|
231114
|
+
{
|
|
231115
|
+
key: "audioNoiseSnapshot",
|
|
231116
|
+
label: "Audio noise (getAudioNoise)"
|
|
231117
|
+
},
|
|
231118
|
+
{
|
|
231119
|
+
key: "autoFocusSnapshot",
|
|
231120
|
+
label: "Auto-focus (getAutoFocus)"
|
|
231121
|
+
},
|
|
231122
|
+
{
|
|
231123
|
+
key: "netPortSnapshot",
|
|
231124
|
+
label: "Network ports (getNetPort)"
|
|
231125
|
+
},
|
|
231126
|
+
{
|
|
231127
|
+
key: "ntpSnapshot",
|
|
231128
|
+
label: "NTP (getNtp)"
|
|
231129
|
+
},
|
|
231130
|
+
{
|
|
231131
|
+
key: "systemGeneralSnapshot",
|
|
231132
|
+
label: "System general (getSystemGeneral)"
|
|
231133
|
+
},
|
|
231134
|
+
{
|
|
231135
|
+
key: "osdSnapshot",
|
|
231136
|
+
label: "OSD overlay (getOsd)"
|
|
231137
|
+
},
|
|
231138
|
+
{
|
|
231139
|
+
key: "ledSnapshot",
|
|
231140
|
+
label: "LEDs (getIrLights)"
|
|
231141
|
+
},
|
|
231142
|
+
{
|
|
231143
|
+
key: "pirSnapshot",
|
|
231144
|
+
label: "PIR (getPirInfo)"
|
|
231145
|
+
},
|
|
231146
|
+
{
|
|
231147
|
+
key: "autoRebootSnapshot",
|
|
231148
|
+
label: "Auto reboot (getAutoReboot)"
|
|
231149
|
+
},
|
|
231150
|
+
{
|
|
231151
|
+
key: "emailConfigSnapshot",
|
|
231152
|
+
label: "Email/SMTP (getEmail)"
|
|
231153
|
+
},
|
|
231154
|
+
{
|
|
231155
|
+
key: "capOptionsSnapshot",
|
|
231156
|
+
label: "Cap option probes (getOptions)"
|
|
231157
|
+
}
|
|
231158
|
+
];
|
|
231159
|
+
/**
|
|
231160
|
+
* Merge freshness stamps for the snapshot keys a persist actually wrote.
|
|
231161
|
+
* Returns a NEW map (immutability) — previous stamps for untouched groups
|
|
231162
|
+
* survive, written groups are stamped `now`. Call this from the same
|
|
231163
|
+
* `setAll` that writes the snapshots, with exactly the keys being written:
|
|
231164
|
+
* a failed probe writes no snapshot and therefore gets no stamp.
|
|
231165
|
+
*/
|
|
231166
|
+
function stampSnapshotFreshness(previous, writtenKeys, now) {
|
|
231167
|
+
const stamped = { ...previous };
|
|
231168
|
+
for (const key of writtenKeys) stamped[key] = now;
|
|
231169
|
+
return stamped;
|
|
231170
|
+
}
|
|
231171
|
+
/**
|
|
231172
|
+
* Resolve the age of one snapshot group from the cache. Sources, in order:
|
|
231173
|
+
* 1. `snapshotFetchedAt[key]` — the generic stamp map;
|
|
231174
|
+
* 2. a group-embedded stamp where one already existed before the map
|
|
231175
|
+
* (`osdSnapshot.fetchedAt`, `emailConfigSnapshot.lastReadAt`,
|
|
231176
|
+
* newest `capOptionsSnapshot[*].fetchedAt`);
|
|
231177
|
+
* 3. otherwise: the group is present but of unknown age.
|
|
231178
|
+
* An absent group is `never` — not-yet-read must never look like read.
|
|
231179
|
+
*/
|
|
231180
|
+
function resolveSnapshotAge(cache, key, now) {
|
|
231181
|
+
if ((cache === void 0 ? void 0 : Reflect.get(cache, key)) === void 0) return { state: "never" };
|
|
231182
|
+
const mapStamp = cache?.snapshotFetchedAt?.[key];
|
|
231183
|
+
const stamp = typeof mapStamp === "number" ? mapStamp : embeddedStamp(cache, key);
|
|
231184
|
+
if (typeof stamp !== "number") return { state: "unknown" };
|
|
231185
|
+
return {
|
|
231186
|
+
state: "known",
|
|
231187
|
+
fetchedAt: stamp,
|
|
231188
|
+
ageMs: Math.max(0, now - stamp)
|
|
231189
|
+
};
|
|
231190
|
+
}
|
|
231191
|
+
/** Pre-map stamps some groups already carried; kept as fallback so a legacy
|
|
231192
|
+
* cache written by today's OSD fix still reports a real age. */
|
|
231193
|
+
function embeddedStamp(cache, key) {
|
|
231194
|
+
if (key === "osdSnapshot") {
|
|
231195
|
+
const v = cache?.osdSnapshot?.fetchedAt;
|
|
231196
|
+
return typeof v === "number" ? v : void 0;
|
|
231197
|
+
}
|
|
231198
|
+
if (key === "emailConfigSnapshot") {
|
|
231199
|
+
const v = cache?.emailConfigSnapshot?.lastReadAt;
|
|
231200
|
+
return typeof v === "number" ? v : void 0;
|
|
231201
|
+
}
|
|
231202
|
+
if (key === "capOptionsSnapshot") {
|
|
231203
|
+
const stamps = Object.values(cache?.capOptionsSnapshot ?? {}).map((e) => e?.fetchedAt).filter((v) => typeof v === "number");
|
|
231204
|
+
return stamps.length > 0 ? Math.max(...stamps) : void 0;
|
|
231205
|
+
}
|
|
231206
|
+
}
|
|
231207
|
+
/** Human age: "12 s ago", "3 m ago", "5 h ago", "12 d ago". */
|
|
231208
|
+
function formatSnapshotAge(ageMs) {
|
|
231209
|
+
const s = Math.floor(ageMs / 1e3);
|
|
231210
|
+
if (s < 60) return `${s} s ago`;
|
|
231211
|
+
const m = Math.floor(s / 60);
|
|
231212
|
+
if (m < 60) return `${m} m ago`;
|
|
231213
|
+
const h = Math.floor(m / 60);
|
|
231214
|
+
if (h < 48) return `${h} h ago`;
|
|
231215
|
+
return `${Math.floor(h / 24)} d ago`;
|
|
231216
|
+
}
|
|
231217
|
+
/** One display line for a group. Unknown age is SAID, never smoothed over. */
|
|
231218
|
+
function formatSnapshotAgeLine(label, age) {
|
|
231219
|
+
switch (age.state) {
|
|
231220
|
+
case "never": return `${label}: never read`;
|
|
231221
|
+
case "unknown": return `${label}: age unknown (recorded before per-snapshot freshness tracking)`;
|
|
231222
|
+
case "known": return `${label}: read ${formatSnapshotAge(age.ageMs)} (${new Date(age.fetchedAt).toLocaleString()})`;
|
|
231223
|
+
}
|
|
231224
|
+
}
|
|
231225
|
+
/**
|
|
231226
|
+
* Read-only "Snapshot freshness" section (advanced tab, next to Debug).
|
|
231227
|
+
* Lists every snapshot group with its own age so "is CamStack's belief
|
|
231228
|
+
* current?" is answerable per fact, not per cache. Purely informational:
|
|
231229
|
+
* it triggers no reads — refresh stays on the existing operator-triggered
|
|
231230
|
+
* "Refresh from camera" action and the event-driven paths.
|
|
231231
|
+
*/
|
|
231232
|
+
function buildSnapshotFreshnessSection(cache, opts) {
|
|
231233
|
+
const lines = SNAPSHOT_GROUPS.map(({ key, label }) => formatSnapshotAgeLine(label, resolveSnapshotAge(cache, key, opts.now)));
|
|
231234
|
+
const probedAt = cache?.probedAt;
|
|
231235
|
+
const header = typeof probedAt === "number" ? `Feature probe: ${new Date(probedAt).toLocaleString()}` : "Feature probe: never recorded";
|
|
231236
|
+
return {
|
|
231237
|
+
id: "snapshotFreshness",
|
|
231238
|
+
tab: "advanced",
|
|
231239
|
+
title: "Snapshot freshness",
|
|
231240
|
+
description: "When each cached camera reading was last fetched. Groups without a stamp were persisted before per-snapshot tracking — their age is unknown, not fresh. Use \"Refresh from camera\" (General) to re-read an awake camera.",
|
|
231241
|
+
columns: 1,
|
|
231242
|
+
fields: [{
|
|
231243
|
+
type: "info",
|
|
231244
|
+
key: "snapshotFreshness",
|
|
231245
|
+
label: "Per-snapshot read times",
|
|
231246
|
+
content: `${opts.sleeping ? "Camera is asleep — no group can refresh until it wakes; a sleeping battery camera is never woken to read settings.\n" : ""}${header}\n${lines.join("\n")}`
|
|
231247
|
+
}]
|
|
231248
|
+
};
|
|
231249
|
+
}
|
|
231250
|
+
var RawReadSliceSchema = _enum([
|
|
231251
|
+
"image",
|
|
231252
|
+
"motion",
|
|
231253
|
+
"ai",
|
|
231254
|
+
"enc",
|
|
231255
|
+
"encOptions",
|
|
231256
|
+
"mask",
|
|
231257
|
+
"audioNoise",
|
|
231258
|
+
"autofocus",
|
|
231259
|
+
"netPort",
|
|
231260
|
+
"ntp",
|
|
231261
|
+
"systemGeneral",
|
|
231262
|
+
"osd",
|
|
231263
|
+
"led",
|
|
231264
|
+
"pir",
|
|
231265
|
+
"autoReboot"
|
|
231266
|
+
]);
|
|
231267
|
+
/**
|
|
231268
|
+
* The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
|
|
231269
|
+
* in lockstep in both directions. Anything not in this map — email/SMTP
|
|
231270
|
+
* (`getEmail` echoes the camera's SMTP credentials), users, sessions, any
|
|
231271
|
+
* `set*` — cannot be requested.
|
|
231272
|
+
*/
|
|
231273
|
+
var RAW_READ_CATALOG = {
|
|
231274
|
+
image: {
|
|
231275
|
+
command: "getVideoInput",
|
|
231276
|
+
snapshotKey: "imageSnapshot",
|
|
231277
|
+
invoke: (api, channel) => api.getVideoInput(channel)
|
|
231278
|
+
},
|
|
231279
|
+
motion: {
|
|
231280
|
+
command: "getMotionAlarm",
|
|
231281
|
+
snapshotKey: "motionSnapshot",
|
|
231282
|
+
invoke: (api, channel) => api.getMotionAlarm(channel)
|
|
231283
|
+
},
|
|
231284
|
+
ai: {
|
|
231285
|
+
command: "getAiDetectTypes + getAiDetectionFull (per type)",
|
|
231286
|
+
snapshotKey: "aiSensitivitySnapshot",
|
|
231287
|
+
invoke: async (api, channel) => {
|
|
231288
|
+
const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
|
|
231289
|
+
const perType = {};
|
|
231290
|
+
for (const aiType of detectTypes ?? []) try {
|
|
231291
|
+
perType[aiType] = await api.getAiDetectionFull(channel, aiType);
|
|
231292
|
+
} catch (err) {
|
|
231293
|
+
perType[aiType] = { error: err instanceof Error ? err.message : String(err) };
|
|
231294
|
+
}
|
|
231295
|
+
return {
|
|
231296
|
+
detectTypes: detectTypes ?? [],
|
|
231297
|
+
perType
|
|
231298
|
+
};
|
|
231299
|
+
}
|
|
231300
|
+
},
|
|
231301
|
+
enc: {
|
|
231302
|
+
command: "getEnc",
|
|
231303
|
+
snapshotKey: "encSnapshot",
|
|
231304
|
+
invoke: (api, channel) => api.getEnc(channel)
|
|
231305
|
+
},
|
|
231306
|
+
encOptions: {
|
|
231307
|
+
command: "getEncOptions",
|
|
231308
|
+
snapshotKey: "encOptionsSnapshot",
|
|
231309
|
+
invoke: (api, channel) => api.getEncOptions(channel)
|
|
231310
|
+
},
|
|
231311
|
+
mask: {
|
|
231312
|
+
command: "getMask",
|
|
231313
|
+
snapshotKey: "maskSnapshot",
|
|
231314
|
+
invoke: (api, channel) => api.getMask(channel)
|
|
231315
|
+
},
|
|
231316
|
+
audioNoise: {
|
|
231317
|
+
command: "getAudioNoise",
|
|
231318
|
+
snapshotKey: "audioNoiseSnapshot",
|
|
231319
|
+
invoke: (api, channel) => api.getAudioNoise(channel)
|
|
231320
|
+
},
|
|
231321
|
+
autofocus: {
|
|
231322
|
+
command: "getAutoFocus",
|
|
231323
|
+
snapshotKey: "autoFocusSnapshot",
|
|
231324
|
+
invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
|
|
231325
|
+
},
|
|
231326
|
+
netPort: {
|
|
231327
|
+
command: "getNetPort",
|
|
231328
|
+
snapshotKey: "netPortSnapshot",
|
|
231329
|
+
invoke: (api) => api.getNetPort()
|
|
231330
|
+
},
|
|
231331
|
+
ntp: {
|
|
231332
|
+
command: "getNtp",
|
|
231333
|
+
snapshotKey: "ntpSnapshot",
|
|
231334
|
+
invoke: (api) => api.getNtp()
|
|
231335
|
+
},
|
|
231336
|
+
systemGeneral: {
|
|
231337
|
+
command: "getSystemGeneral",
|
|
231338
|
+
snapshotKey: "systemGeneralSnapshot",
|
|
231339
|
+
invoke: (api) => api.getSystemGeneral()
|
|
231340
|
+
},
|
|
231341
|
+
osd: {
|
|
231342
|
+
command: "getOsd",
|
|
231343
|
+
snapshotKey: "osdSnapshot",
|
|
231344
|
+
invoke: (api, channel) => api.getOsd(channel)
|
|
231345
|
+
},
|
|
231346
|
+
led: {
|
|
231347
|
+
command: "getIrLights",
|
|
231348
|
+
snapshotKey: "ledSnapshot",
|
|
231349
|
+
invoke: (api, channel) => api.getIrLights(channel)
|
|
231350
|
+
},
|
|
231351
|
+
pir: {
|
|
231352
|
+
command: "getPirInfo",
|
|
231353
|
+
snapshotKey: "pirSnapshot",
|
|
231354
|
+
invoke: (api, channel) => api.getPirInfo(channel)
|
|
231355
|
+
},
|
|
231356
|
+
autoReboot: {
|
|
231357
|
+
command: "getAutoReboot",
|
|
231358
|
+
snapshotKey: "autoRebootSnapshot",
|
|
231359
|
+
invoke: (api) => api.getAutoReboot()
|
|
231360
|
+
}
|
|
231361
|
+
};
|
|
231362
|
+
/** What CamStack currently believes about the slice, with its own age. */
|
|
231363
|
+
var BelievedStateSchema = object({
|
|
231364
|
+
/** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
|
|
231365
|
+
snapshot: unknown(),
|
|
231366
|
+
/** deviceCache field the projection lives in. */
|
|
231367
|
+
snapshotKey: string(),
|
|
231368
|
+
/** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
|
|
231369
|
+
age: union([
|
|
231370
|
+
object({ state: literal("never") }),
|
|
231371
|
+
object({ state: literal("unknown") }),
|
|
231372
|
+
object({
|
|
231373
|
+
state: literal("known"),
|
|
231374
|
+
fetchedAt: number().int(),
|
|
231375
|
+
ageMs: number().int().nonnegative()
|
|
231376
|
+
})
|
|
231377
|
+
])
|
|
231378
|
+
});
|
|
231379
|
+
var RawReadResultSchema = discriminatedUnion("ok", [object({
|
|
231380
|
+
ok: literal(true),
|
|
231381
|
+
deviceId: number().int(),
|
|
231382
|
+
slice: RawReadSliceSchema,
|
|
231383
|
+
command: string(),
|
|
231384
|
+
readAt: number().int(),
|
|
231385
|
+
/** The library's response, unprojected, as plain JSON. */
|
|
231386
|
+
camera: unknown(),
|
|
231387
|
+
believed: BelievedStateSchema
|
|
231388
|
+
}), object({
|
|
231389
|
+
ok: literal(false),
|
|
231390
|
+
deviceId: number().int(),
|
|
231391
|
+
slice: RawReadSliceSchema,
|
|
231392
|
+
reason: _enum([
|
|
231393
|
+
"sleeping",
|
|
231394
|
+
"login-failed",
|
|
231395
|
+
"read-failed"
|
|
231396
|
+
]),
|
|
231397
|
+
message: string(),
|
|
231398
|
+
/** The believed state is still reported — a refusal must not hide
|
|
231399
|
+
* what CamStack is currently serving. */
|
|
231400
|
+
believed: BelievedStateSchema
|
|
231401
|
+
})]);
|
|
231402
|
+
var RawReadInputSchema = object({
|
|
231403
|
+
deviceId: number().int().nonnegative(),
|
|
231404
|
+
slice: RawReadSliceSchema
|
|
231405
|
+
});
|
|
231406
|
+
function believedState(cache, entry, now) {
|
|
231407
|
+
const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
|
|
231408
|
+
return {
|
|
231409
|
+
snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
|
|
231410
|
+
snapshotKey: entry.snapshotKey,
|
|
231411
|
+
age
|
|
231412
|
+
};
|
|
231413
|
+
}
|
|
231414
|
+
/** Force a lib response to plain JSON: drops functions/undefined/prototypes,
|
|
231415
|
+
* guarantees the payload is serializable across the tRPC boundary. */
|
|
231416
|
+
function toPlainJson(value) {
|
|
231417
|
+
if (value === void 0) return null;
|
|
231418
|
+
return JSON.parse(JSON.stringify(value));
|
|
231419
|
+
}
|
|
231420
|
+
/**
|
|
231421
|
+
* Execute one raw read. Order matters:
|
|
231422
|
+
* 1. sleep gate (refuse loudly — logging in would BE the wake);
|
|
231423
|
+
* 2. login;
|
|
231424
|
+
* 3. the allow-listed read;
|
|
231425
|
+
* and every outcome — refusal included — carries the believed state so the
|
|
231426
|
+
* operator always sees both sides of the comparison.
|
|
231427
|
+
*/
|
|
231428
|
+
async function performRawRead(slice, deps) {
|
|
231429
|
+
const entry = RAW_READ_CATALOG[slice];
|
|
231430
|
+
const believed = believedState(deps.cache, entry, deps.now);
|
|
231431
|
+
if (deps.sleeping) {
|
|
231432
|
+
deps.logger.info("reolink raw read refused — battery cam is sleeping", {
|
|
231433
|
+
tags: { deviceId: deps.deviceId },
|
|
231434
|
+
meta: { slice }
|
|
231435
|
+
});
|
|
231436
|
+
return {
|
|
231437
|
+
ok: false,
|
|
231438
|
+
deviceId: deps.deviceId,
|
|
231439
|
+
slice,
|
|
231440
|
+
reason: "sleeping",
|
|
231441
|
+
message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
|
|
231442
|
+
believed
|
|
231443
|
+
};
|
|
231444
|
+
}
|
|
231445
|
+
let api;
|
|
231446
|
+
try {
|
|
231447
|
+
api = await deps.getApi();
|
|
231448
|
+
} catch (err) {
|
|
231449
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231450
|
+
deps.logger.info("reolink raw read login failed", {
|
|
231451
|
+
tags: { deviceId: deps.deviceId },
|
|
231452
|
+
meta: {
|
|
231453
|
+
slice,
|
|
231454
|
+
error: message
|
|
231455
|
+
}
|
|
231456
|
+
});
|
|
231457
|
+
return {
|
|
231458
|
+
ok: false,
|
|
231459
|
+
deviceId: deps.deviceId,
|
|
231460
|
+
slice,
|
|
231461
|
+
reason: "login-failed",
|
|
231462
|
+
message,
|
|
231463
|
+
believed
|
|
231464
|
+
};
|
|
231465
|
+
}
|
|
231466
|
+
try {
|
|
231467
|
+
const payload = await entry.invoke(api, deps.channel);
|
|
231468
|
+
deps.logger.info("reolink raw read served", {
|
|
231469
|
+
tags: { deviceId: deps.deviceId },
|
|
231470
|
+
meta: {
|
|
231471
|
+
slice,
|
|
231472
|
+
command: entry.command
|
|
231473
|
+
}
|
|
231474
|
+
});
|
|
231475
|
+
return {
|
|
231476
|
+
ok: true,
|
|
231477
|
+
deviceId: deps.deviceId,
|
|
231478
|
+
slice,
|
|
231479
|
+
command: entry.command,
|
|
231480
|
+
readAt: deps.now,
|
|
231481
|
+
camera: toPlainJson(payload),
|
|
231482
|
+
believed
|
|
231483
|
+
};
|
|
231484
|
+
} catch (err) {
|
|
231485
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231486
|
+
deps.logger.info("reolink raw read failed", {
|
|
231487
|
+
tags: { deviceId: deps.deviceId },
|
|
231488
|
+
meta: {
|
|
231489
|
+
slice,
|
|
231490
|
+
command: entry.command,
|
|
231491
|
+
error: message
|
|
231492
|
+
}
|
|
231493
|
+
});
|
|
231494
|
+
return {
|
|
231495
|
+
ok: false,
|
|
231496
|
+
deviceId: deps.deviceId,
|
|
231497
|
+
slice,
|
|
231498
|
+
reason: "read-failed",
|
|
231499
|
+
message,
|
|
231500
|
+
believed
|
|
231501
|
+
};
|
|
231502
|
+
}
|
|
231503
|
+
}
|
|
231504
|
+
//#endregion
|
|
231505
|
+
//#region src/debug-actions.ts
|
|
231506
|
+
/**
|
|
231507
|
+
* provider-reolink — customActions catalog (admin-only debug surface).
|
|
231508
|
+
*
|
|
231509
|
+
* Dispatched via `POST addons.custom
|
|
231510
|
+
* {addonId:'provider-reolink', action:'debugRawRead', input:{deviceId, slice}}`.
|
|
231511
|
+
*
|
|
231512
|
+
* Why an addon custom action and not a device action: `deviceManager.
|
|
231513
|
+
* runDeviceAction` (the `refresh-settings` / `refresh-sessions` shape) is
|
|
231514
|
+
* mounted `protected` and its dispatcher does not enforce the per-action
|
|
231515
|
+
* `auth` — any authenticated user could call it. `addons.custom` is the one
|
|
231516
|
+
* operator surface that enforces per-action `auth: 'admin'` server-side
|
|
231517
|
+
* (`ensureCustomActionAuth`) AND validates the addon's output against this
|
|
231518
|
+
* catalog. A debug surface that returns raw camera payloads is admin-only,
|
|
231519
|
+
* so it lives here (same wiring as `addon-benchmark` / `addon-notifiers`).
|
|
231520
|
+
*
|
|
231521
|
+
* `kind: 'query'` states the contract — the handler is read-only by
|
|
231522
|
+
* construction (see `raw-read.ts`: the slice enum maps onto an allow-list
|
|
231523
|
+
* of lib `get*` calls; no write is reachable). The `addons.custom` mount
|
|
231524
|
+
* itself is a single mutation procedure, so callers still POST.
|
|
231525
|
+
*/
|
|
231526
|
+
var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawReadInputSchema, RawReadResultSchema, {
|
|
231527
|
+
kind: "query",
|
|
231528
|
+
auth: "admin"
|
|
231529
|
+
}) });
|
|
231530
|
+
//#endregion
|
|
230695
231531
|
//#region src/log-channels.ts
|
|
230696
231532
|
/**
|
|
230697
231533
|
* The diagnostic log CHANNELS `provider-reolink` declares.
|
|
@@ -230904,6 +231740,130 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
|
|
|
230904
231740
|
});
|
|
230905
231741
|
}
|
|
230906
231742
|
//#endregion
|
|
231743
|
+
//#region src/osd-settings-section.ts
|
|
231744
|
+
/**
|
|
231745
|
+
* Camera snapshot → UNKNOWN (`null`). The last arm is the point: `?? true`
|
|
231746
|
+
* here is what rendered "the camera did not tell us" as an enabled overlay.
|
|
231747
|
+
* The snapshot is the ONLY input — the config keeps no copy to consult.
|
|
231748
|
+
*/
|
|
231749
|
+
function resolveOsdValues(snapshot) {
|
|
231750
|
+
return {
|
|
231751
|
+
osdChannelEnabled: snapshot?.channelEnabled ?? null,
|
|
231752
|
+
osdChannelName: snapshot?.channelName ?? "",
|
|
231753
|
+
osdTimeEnabled: snapshot?.timeEnabled ?? null,
|
|
231754
|
+
osdWatermark: snapshot?.watermark ?? null
|
|
231755
|
+
};
|
|
231756
|
+
}
|
|
231757
|
+
/**
|
|
231758
|
+
* Reader-side staleness (D224). A snapshot persisted before freshness
|
|
231759
|
+
* tracking has no `fetchedAt` and is treated as stale — that is exactly the
|
|
231760
|
+
* adoption-frozen reading this module exists to retire.
|
|
231761
|
+
*/
|
|
231762
|
+
function isOsdSnapshotStale(snapshot, now) {
|
|
231763
|
+
return now - (snapshot?.fetchedAt ?? 0) > OPERATOR_WRITTEN_STALE_MS;
|
|
231764
|
+
}
|
|
231765
|
+
var OSD_UNKNOWN_DESCRIPTION = "Not reported by the camera yet — the current state is unknown.";
|
|
231766
|
+
var NEVER_WOKEN_SUFFIX = "a sleeping battery camera is never woken to read settings.";
|
|
231767
|
+
/**
|
|
231768
|
+
* A boolean overlay toggle. Unknown (`null`) renders disabled with an honest
|
|
231769
|
+
* description — the switch component shows `Boolean(null)` = off, and the
|
|
231770
|
+
* disabled + "not reported" pairing keeps that from reading as a claim.
|
|
231771
|
+
*/
|
|
231772
|
+
function osdToggle(key, label, value, baseDescription) {
|
|
231773
|
+
const unknown = value === null;
|
|
231774
|
+
const description = unknown ? baseDescription ? `${baseDescription} ${OSD_UNKNOWN_DESCRIPTION}` : OSD_UNKNOWN_DESCRIPTION : baseDescription;
|
|
231775
|
+
return {
|
|
231776
|
+
type: "boolean",
|
|
231777
|
+
key,
|
|
231778
|
+
label,
|
|
231779
|
+
default: value,
|
|
231780
|
+
style: "switch",
|
|
231781
|
+
...description !== void 0 ? { description } : {},
|
|
231782
|
+
...unknown ? { disabled: true } : {}
|
|
231783
|
+
};
|
|
231784
|
+
}
|
|
231785
|
+
/**
|
|
231786
|
+
* State banner shown when the operator is NOT looking at a current reading:
|
|
231787
|
+
* - camera asleep and the mirror is stale → say what is shown and when it
|
|
231788
|
+
* was read, and that the camera is not woken for this;
|
|
231789
|
+
* - no reading has ever landed → say the toggles are unknown.
|
|
231790
|
+
* A fresh mirror on an awake camera renders no banner — serve-and-revalidate
|
|
231791
|
+
* keeps it honest silently.
|
|
231792
|
+
*/
|
|
231793
|
+
function buildOsdStateBanner(snapshot, opts) {
|
|
231794
|
+
const stale = isOsdSnapshotStale(snapshot, opts.now);
|
|
231795
|
+
if (opts.sleeping && stale) return {
|
|
231796
|
+
type: "info",
|
|
231797
|
+
key: "osdSnapshotState",
|
|
231798
|
+
label: "OSD state",
|
|
231799
|
+
variant: "warning",
|
|
231800
|
+
content: snapshot === void 0 ? `Camera is asleep and its OSD state has never been read — the toggles below are unknown until it wakes; ${NEVER_WOKEN_SUFFIX}` : snapshot.fetchedAt !== void 0 ? `Camera is asleep — showing the OSD state last read ${new Date(snapshot.fetchedAt).toLocaleString()}. It refreshes when the camera wakes; ${NEVER_WOKEN_SUFFIX}` : `Camera is asleep — showing the last known OSD state (age unknown). It refreshes when the camera wakes; ${NEVER_WOKEN_SUFFIX}`
|
|
231801
|
+
};
|
|
231802
|
+
if (snapshot === void 0) return {
|
|
231803
|
+
type: "info",
|
|
231804
|
+
key: "osdSnapshotState",
|
|
231805
|
+
label: "OSD state",
|
|
231806
|
+
variant: "warning",
|
|
231807
|
+
content: "OSD state has not been read from this camera yet — unknown toggles are disabled until a read succeeds."
|
|
231808
|
+
};
|
|
231809
|
+
return null;
|
|
231810
|
+
}
|
|
231811
|
+
/**
|
|
231812
|
+
* A position value for display. Verbatim in quotes when the camera reported
|
|
231813
|
+
* one — an empty string IS a report and shows as `""` — and "not reported"
|
|
231814
|
+
* only when `getOsd` genuinely carried no string (tri-state, D337).
|
|
231815
|
+
*/
|
|
231816
|
+
function formatObservedPos(pos) {
|
|
231817
|
+
return typeof pos === "string" ? `"${pos}"` : "not reported";
|
|
231818
|
+
}
|
|
231819
|
+
/**
|
|
231820
|
+
* Read-only view of the overlay positions the camera reported. Deliberately
|
|
231821
|
+
* NOT a control: the `pos` vocabulary is unknown (loose string, no observed
|
|
231822
|
+
* values yet), so this field exists to make it observable per camera. A
|
|
231823
|
+
* position control can be designed once real values have been collected —
|
|
231824
|
+
* see the "osd overlay positions observed" info log in the probe.
|
|
231825
|
+
*/
|
|
231826
|
+
function buildOsdPositionsField(snapshot) {
|
|
231827
|
+
return {
|
|
231828
|
+
type: "info",
|
|
231829
|
+
key: "osdPositions",
|
|
231830
|
+
label: "Overlay positions",
|
|
231831
|
+
content: `Positions are kept exactly as configured on the camera and are read-only here.\nChannel name: ${formatObservedPos(snapshot?.channelPos)}\nTimestamp: ${formatObservedPos(snapshot?.timePos)}`
|
|
231832
|
+
};
|
|
231833
|
+
}
|
|
231834
|
+
/**
|
|
231835
|
+
* The "OSD overlay" section (writable via `setOsd`, cmd_id 25). One
|
|
231836
|
+
* read-modify-write `setOsd(OsdConfig)` push covers all four fields — the
|
|
231837
|
+
* dispatcher reads the current `OsdConfig` (`getOsd`) first so the stored
|
|
231838
|
+
* overlay positions (`pos`) survive untouched. Position itself is
|
|
231839
|
+
* camera-pixel/preset specific and only OBSERVED here, never written.
|
|
231840
|
+
*/
|
|
231841
|
+
function buildOsdSection(snapshot, values, opts) {
|
|
231842
|
+
const banner = buildOsdStateBanner(snapshot, opts);
|
|
231843
|
+
return {
|
|
231844
|
+
id: "osd",
|
|
231845
|
+
tab: "image",
|
|
231846
|
+
title: "OSD overlay",
|
|
231847
|
+
description: "On-screen overlays burned into the video by the camera. Pushed via `SetOsd` (cmd_id 25). Overlay positions are kept as configured on the camera — only the toggles, channel-name text, and watermark change here.",
|
|
231848
|
+
columns: 2,
|
|
231849
|
+
fields: [
|
|
231850
|
+
...banner ? [banner] : [],
|
|
231851
|
+
osdToggle("osdChannelEnabled", "Channel name overlay", values.osdChannelEnabled, void 0),
|
|
231852
|
+
{
|
|
231853
|
+
type: "text",
|
|
231854
|
+
key: "osdChannelName",
|
|
231855
|
+
label: "Channel name",
|
|
231856
|
+
description: "Text shown in the channel-name overlay.",
|
|
231857
|
+
default: values.osdChannelName,
|
|
231858
|
+
placeholder: "Front door"
|
|
231859
|
+
},
|
|
231860
|
+
osdToggle("osdTimeEnabled", "Timestamp overlay", values.osdTimeEnabled, void 0),
|
|
231861
|
+
osdToggle("osdWatermark", "Watermark", values.osdWatermark, "The Reolink logo watermark overlay."),
|
|
231862
|
+
buildOsdPositionsField(snapshot)
|
|
231863
|
+
]
|
|
231864
|
+
};
|
|
231865
|
+
}
|
|
231866
|
+
//#endregion
|
|
230907
231867
|
//#region src/raw-state.ts
|
|
230908
231868
|
/**
|
|
230909
231869
|
* Source tag for every raw-state blob this provider emits.
|
|
@@ -231138,7 +232098,7 @@ var SirenAccessory = class extends BaseDevice {
|
|
|
231138
232098
|
this.ctx.logger.info("siren onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
|
|
231139
232099
|
try {
|
|
231140
232100
|
await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
|
|
231141
|
-
await sleep$
|
|
232101
|
+
await sleep$2(1e3);
|
|
231142
232102
|
} catch (err) {
|
|
231143
232103
|
this.ctx.logger.warn("siren wake before initial probe failed — proceeding anyway", {
|
|
231144
232104
|
tags: { deviceId: this.id },
|
|
@@ -231560,7 +232520,7 @@ var FloodlightAccessory = class extends BaseDevice {
|
|
|
231560
232520
|
this.ctx.logger.info("floodlight onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
|
|
231561
232521
|
try {
|
|
231562
232522
|
await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
|
|
231563
|
-
await sleep$
|
|
232523
|
+
await sleep$2(1e3);
|
|
231564
232524
|
} catch (err) {
|
|
231565
232525
|
this.ctx.logger.warn("floodlight wake before initial probe failed — proceeding anyway", {
|
|
231566
232526
|
tags: { deviceId: this.id },
|
|
@@ -231957,7 +232917,7 @@ var PirAccessory = class extends BaseDevice {
|
|
|
231957
232917
|
this.ctx.logger.info("pir onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
|
|
231958
232918
|
try {
|
|
231959
232919
|
await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
|
|
231960
|
-
await sleep$
|
|
232920
|
+
await sleep$2(1e3);
|
|
231961
232921
|
} catch (err) {
|
|
231962
232922
|
this.ctx.logger.warn("pir wake before initial probe failed — proceeding anyway", {
|
|
231963
232923
|
tags: { deviceId: this.id },
|
|
@@ -233631,7 +234591,24 @@ var reolinkCameraSchema = object({
|
|
|
233631
234591
|
channelEnabled: boolean().nullable().optional(),
|
|
233632
234592
|
channelName: string().optional(),
|
|
233633
234593
|
timeEnabled: boolean().nullable().optional(),
|
|
233634
|
-
watermark: boolean().nullable().optional()
|
|
234594
|
+
watermark: boolean().nullable().optional(),
|
|
234595
|
+
/**
|
|
234596
|
+
* Overlay positions exactly as `getOsd` reported them — READ-ONLY
|
|
234597
|
+
* observations, never written (the `setOsd` read-modify-write
|
|
234598
|
+
* preserves the camera's stored `pos`). Tri-state: `null` means
|
|
234599
|
+
* the camera did not report a string; an empty string is a real
|
|
234600
|
+
* report. Captured so a vocabulary of live values can be
|
|
234601
|
+
* collected before any position control is designed.
|
|
234602
|
+
*/
|
|
234603
|
+
channelPos: string().nullable().optional(),
|
|
234604
|
+
timePos: string().nullable().optional(),
|
|
234605
|
+
/**
|
|
234606
|
+
* Wall-clock ms when this slice last landed from the camera.
|
|
234607
|
+
* Absent on snapshots persisted before freshness tracking —
|
|
234608
|
+
* `isOsdSnapshotStale` treats those as stale, which retires the
|
|
234609
|
+
* adoption-frozen readings this stamp was added for (D224).
|
|
234610
|
+
*/
|
|
234611
|
+
fetchedAt: number().int().optional()
|
|
233635
234612
|
}).optional(),
|
|
233636
234613
|
/**
|
|
233637
234614
|
* Snapshot of the camera's status + doorbell LED state from
|
|
@@ -233670,7 +234647,18 @@ var reolinkCameraSchema = object({
|
|
|
233670
234647
|
hour: number().int().nullable().optional(),
|
|
233671
234648
|
minute: number().int().nullable().optional(),
|
|
233672
234649
|
supported: boolean().optional()
|
|
233673
|
-
}).optional()
|
|
234650
|
+
}).optional(),
|
|
234651
|
+
/**
|
|
234652
|
+
* Per-snapshot freshness stamps (D224 generalised, D346): wall-clock
|
|
234653
|
+
* ms when each `*Snapshot` group in this cache was last WRITTEN from
|
|
234654
|
+
* a camera read, keyed by the group's field name (`encSnapshot`,
|
|
234655
|
+
* `osdSnapshot`, …). Written only by the persist sites that write
|
|
234656
|
+
* the group itself (`stampSnapshotFreshness`) — a failed probe
|
|
234657
|
+
* writes no snapshot and gets no stamp. A group with no entry here
|
|
234658
|
+
* (legacy persist) is of UNKNOWN age and must never read as fresh;
|
|
234659
|
+
* `resolveSnapshotAge` owns the tri-state.
|
|
234660
|
+
*/
|
|
234661
|
+
snapshotFetchedAt: record(string(), number().int()).optional()
|
|
233674
234662
|
}).loose().optional(),
|
|
233675
234663
|
/**
|
|
233676
234664
|
* Generic Baichuan debug logs. Forwarded as `DebugOptions.general`
|
|
@@ -233828,18 +234816,6 @@ var reolinkCameraSchema = object({
|
|
|
233828
234816
|
statusLedEnabled: boolean().optional(),
|
|
233829
234817
|
doorbellLedEnabled: boolean().optional(),
|
|
233830
234818
|
/**
|
|
233831
|
-
* On-screen display (OSD) overlay — pushed via `setOsd` (cmd_id 25,
|
|
233832
|
-
* read via 26). One read-modify-write `OsdConfig` push covers all four
|
|
233833
|
-
* fields so the camera keeps its stored overlay positions (`pos`)
|
|
233834
|
-
* untouched — only the enable flags, channel name text, and watermark
|
|
233835
|
-
* toggle change. Position is camera-pixel/preset specific (`pos` is a
|
|
233836
|
-
* loose string, not a clean enum), so it is intentionally NOT exposed.
|
|
233837
|
-
*/
|
|
233838
|
-
osdChannelEnabled: boolean().optional(),
|
|
233839
|
-
osdChannelName: string().max(64).optional(),
|
|
233840
|
-
osdTimeEnabled: boolean().optional(),
|
|
233841
|
-
osdWatermark: boolean().optional(),
|
|
233842
|
-
/**
|
|
233843
234819
|
* Audio output volume — pushed via `setAudioCfg` (cmd_id=265,
|
|
233844
234820
|
* read via 264). Reolink-spec range 0..100.
|
|
233845
234821
|
*/
|
|
@@ -235038,6 +236014,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235038
236014
|
* legacy firmware that doesn't support some endpoints). */
|
|
235039
236015
|
lastSettingsSnapshotRetryAt = 0;
|
|
235040
236016
|
static SETTINGS_SNAPSHOT_RETRY_MIN_MS = 6e4;
|
|
236017
|
+
/** Debounce timestamp for the OSD serve-and-revalidate kick from
|
|
236018
|
+
* `getSettingsUISchema` (D224). Separate from
|
|
236019
|
+
* `lastSettingsSnapshotRetryAt` so an incomplete-cache retry and an
|
|
236020
|
+
* OSD staleness revalidate never suppress each other. */
|
|
236021
|
+
lastOsdRevalidateKickAt = 0;
|
|
235041
236022
|
/** True when any settings-snapshot field that drives a UI section
|
|
235042
236023
|
* is missing from the persisted cache. Drives the on-demand retry
|
|
235043
236024
|
* in `getSettingsUISchema`. */
|
|
@@ -235047,16 +236028,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235047
236028
|
return cache.encSnapshot === void 0 || cache.encOptionsSnapshot === void 0 || cache.maskSnapshot === void 0 || cache.audioNoiseSnapshot === void 0 || cache.autoFocusSnapshot === void 0;
|
|
235048
236029
|
}
|
|
235049
236030
|
/**
|
|
235050
|
-
* Probe `getVideoInput
|
|
235051
|
-
* `deviceCache` snapshots.
|
|
235052
|
-
*
|
|
235053
|
-
*
|
|
235054
|
-
*
|
|
235055
|
-
*
|
|
236031
|
+
* Probe the parent-settings endpoints (`getVideoInput`, `getMotionAlarm`,
|
|
236032
|
+
* `getOsd`, …) and persist into the `deviceCache` snapshots. Runs on
|
|
236033
|
+
* activation, on battery wake transitions, after a settings save (scoped
|
|
236034
|
+
* to the changed slices), via the manual "Refresh from camera" action,
|
|
236035
|
+
* and from `getSettingsUISchema`'s serve-and-revalidate kicks — settings
|
|
236036
|
+
* opens serve the persisted snapshot immediately and revalidate stale
|
|
236037
|
+
* slices behind the form (D224). Image is readonly in the UI (lib lacks
|
|
236038
|
+
* `setVideoInput`); motion is writable via `setMotionAlarm` so its
|
|
236039
|
+
* snapshot also drives the dispatch's known-good baseline.
|
|
235056
236040
|
*/
|
|
235057
236041
|
async refreshParentSettingsSnapshot(slices) {
|
|
235058
236042
|
if (this.isBattery && this.sleeping) {
|
|
235059
|
-
this.ctx.logger.
|
|
236043
|
+
this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
|
|
235060
236044
|
return;
|
|
235061
236045
|
}
|
|
235062
236046
|
let api;
|
|
@@ -235209,14 +236193,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235209
236193
|
}
|
|
235210
236194
|
if (want("osd")) try {
|
|
235211
236195
|
const osd = await api.getOsd(channel);
|
|
236196
|
+
const channelPos = typeof osd.osdChannel?.pos === "string" ? osd.osdChannel.pos : null;
|
|
236197
|
+
const timePos = typeof osd.osdTime?.pos === "string" ? osd.osdTime.pos : null;
|
|
236198
|
+
const prevOsdSnapshot = this.config.get("deviceCache")?.osdSnapshot;
|
|
236199
|
+
if (prevOsdSnapshot?.channelPos !== channelPos || prevOsdSnapshot?.timePos !== timePos) this.ctx.logger.info("reolink osd overlay positions observed", {
|
|
236200
|
+
tags: { deviceId: this.id },
|
|
236201
|
+
meta: {
|
|
236202
|
+
channelPos,
|
|
236203
|
+
timePos
|
|
236204
|
+
}
|
|
236205
|
+
});
|
|
235212
236206
|
cacheUpdate.osdSnapshot = {
|
|
235213
236207
|
channelEnabled: typeof osd.osdChannel?.enable === "number" ? osd.osdChannel.enable === 1 : null,
|
|
235214
236208
|
channelName: typeof osd.osdChannel?.name === "string" ? osd.osdChannel.name : void 0,
|
|
235215
236209
|
timeEnabled: typeof osd.osdTime?.enable === "number" ? osd.osdTime.enable === 1 : null,
|
|
235216
|
-
watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null
|
|
236210
|
+
watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null,
|
|
236211
|
+
channelPos,
|
|
236212
|
+
timePos,
|
|
236213
|
+
fetchedAt: Date.now()
|
|
235217
236214
|
};
|
|
235218
236215
|
} catch (err) {
|
|
235219
|
-
this.ctx.logger.
|
|
236216
|
+
this.ctx.logger.info("reolink getOsd probe failed — OSD snapshot left stale", {
|
|
236217
|
+
tags: { deviceId: this.id },
|
|
236218
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
236219
|
+
});
|
|
235220
236220
|
}
|
|
235221
236221
|
if (want("led")) try {
|
|
235222
236222
|
const ledState = (await api.getIrLights(channel))?.body?.LedState;
|
|
@@ -235273,6 +236273,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235273
236273
|
}
|
|
235274
236274
|
if (Object.keys(cacheUpdate).length === 0) return;
|
|
235275
236275
|
const current = this.config.get("deviceCache") ?? {};
|
|
236276
|
+
const writtenSnapshotKeys = Object.keys(cacheUpdate).filter((k) => k.endsWith("Snapshot"));
|
|
236277
|
+
if (writtenSnapshotKeys.length > 0) cacheUpdate.snapshotFetchedAt = stampSnapshotFreshness(current.snapshotFetchedAt, writtenSnapshotKeys, Date.now());
|
|
235276
236278
|
try {
|
|
235277
236279
|
await this.config.setAll({ deviceCache: {
|
|
235278
236280
|
...current,
|
|
@@ -235286,6 +236288,28 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235286
236288
|
});
|
|
235287
236289
|
}
|
|
235288
236290
|
/**
|
|
236291
|
+
* Admin-only raw-read debug surface (D346): return the library's response
|
|
236292
|
+
* for one snapshot slice UNPROJECTED, next to the persisted snapshot and
|
|
236293
|
+
* its freshness, so "what does the camera report vs what does CamStack
|
|
236294
|
+
* believe" is answerable without Baichuan tracing. Read-only by
|
|
236295
|
+
* construction (the slice enum maps onto an allow-list of lib `get*`
|
|
236296
|
+
* calls — see `raw-read.ts`), writes nothing back to the cache, and the
|
|
236297
|
+
* sleep gate runs BEFORE any login: a sleeping battery cam refuses loudly
|
|
236298
|
+
* (logging in IS the wake) and still reports the believed state.
|
|
236299
|
+
* Dispatched by the provider's `debugRawRead` custom action.
|
|
236300
|
+
*/
|
|
236301
|
+
async debugRawRead(slice) {
|
|
236302
|
+
return performRawRead(slice, {
|
|
236303
|
+
deviceId: this.id,
|
|
236304
|
+
channel: this.getChannel(),
|
|
236305
|
+
sleeping: this.isBattery && this.sleeping,
|
|
236306
|
+
getApi: () => this.ensureApi(),
|
|
236307
|
+
cache: this.config.get("deviceCache"),
|
|
236308
|
+
logger: this.ctx.logger,
|
|
236309
|
+
now: Date.now()
|
|
236310
|
+
});
|
|
236311
|
+
}
|
|
236312
|
+
/**
|
|
235289
236313
|
* Declare on-camera accessory child devices the kernel should
|
|
235290
236314
|
* auto-spawn after `onCreated`. Each entry maps directly to a
|
|
235291
236315
|
* concrete accessory class via the existing `createAccessoryDevice`
|
|
@@ -235369,7 +236393,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235369
236393
|
waitAfterWakeMs: 2500,
|
|
235370
236394
|
attempts: 2
|
|
235371
236395
|
});
|
|
235372
|
-
await sleep$
|
|
236396
|
+
await sleep$2(1500);
|
|
235373
236397
|
})(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
|
|
235374
236398
|
return true;
|
|
235375
236399
|
} catch (err) {
|
|
@@ -235484,7 +236508,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235484
236508
|
sendNickname: email.sendNickname,
|
|
235485
236509
|
...task ? { taskEnabled: task.enable === 1 } : {},
|
|
235486
236510
|
lastReadAt: Date.now()
|
|
235487
|
-
}
|
|
236511
|
+
},
|
|
236512
|
+
snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["emailConfigSnapshot"], Date.now())
|
|
235488
236513
|
} });
|
|
235489
236514
|
this.ctx.logger.info("email-push: read camera email config", {
|
|
235490
236515
|
tags: { deviceId: this.id },
|
|
@@ -235666,7 +236691,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235666
236691
|
waitAfterWakeMs: 2500,
|
|
235667
236692
|
attempts: 2
|
|
235668
236693
|
});
|
|
235669
|
-
await sleep$
|
|
236694
|
+
await sleep$2(1500);
|
|
235670
236695
|
})(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
|
|
235671
236696
|
const CONFIRM_TIMEOUT_MS = 1e4;
|
|
235672
236697
|
const CONFIRM_POLL_MS = 1e3;
|
|
@@ -235696,7 +236721,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235696
236721
|
break;
|
|
235697
236722
|
}
|
|
235698
236723
|
}
|
|
235699
|
-
await sleep$
|
|
236724
|
+
await sleep$2(CONFIRM_POLL_MS);
|
|
235700
236725
|
}
|
|
235701
236726
|
const confirmSource = parent !== null ? "hub-summary" : "sleep-poll";
|
|
235702
236727
|
if (observedAwake && this.commitSleepState(false, confirmSource)) {
|
|
@@ -235928,7 +236953,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235928
236953
|
async watchHubChildAwake(parent) {
|
|
235929
236954
|
const deadline = Date.now() + 45e3;
|
|
235930
236955
|
while (Date.now() < deadline) {
|
|
235931
|
-
await sleep$
|
|
236956
|
+
await sleep$2(5e3);
|
|
235932
236957
|
try {
|
|
235933
236958
|
if ((await (await parent.getApi()).getNvrChannelsSummary({ channels: [this.getChannel()] })).devices.find((d) => d.channel === this.getChannel())?.sleeping === false) {
|
|
235934
236959
|
if (this.commitSleepState(false, "hub-summary")) {
|
|
@@ -236891,7 +237916,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
236891
237916
|
value,
|
|
236892
237917
|
fetchedAt: Date.now()
|
|
236893
237918
|
}
|
|
236894
|
-
}
|
|
237919
|
+
},
|
|
237920
|
+
snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["capOptionsSnapshot"], Date.now())
|
|
236895
237921
|
} });
|
|
236896
237922
|
} catch (err) {
|
|
236897
237923
|
this.ctx.logger.debug("cap options persist failed", {
|
|
@@ -237956,7 +238982,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
237956
238982
|
this.ctx.logger.info("intercom: cam sleeping — waking up before talk session", { tags: { deviceId: this.id } });
|
|
237957
238983
|
try {
|
|
237958
238984
|
await api.wakeUp(channel, { waitAfterWakeMs: 2e3 });
|
|
237959
|
-
await sleep$
|
|
238985
|
+
await sleep$2(1e3);
|
|
237960
238986
|
} catch (err) {
|
|
237961
238987
|
this.ctx.logger.warn("intercom: wakeUp failed — proceeding anyway", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
237962
238988
|
}
|
|
@@ -238256,13 +239282,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
238256
239282
|
await api.setAutoFocus(channel, enabled ? 0 : 1);
|
|
238257
239283
|
try {
|
|
238258
239284
|
const a = (await api.getAutoFocus(channel, { timeoutMs: 1500 }))?.body?.AutoFocus;
|
|
238259
|
-
if (a)
|
|
238260
|
-
|
|
238261
|
-
|
|
238262
|
-
|
|
238263
|
-
|
|
238264
|
-
|
|
238265
|
-
|
|
239285
|
+
if (a) {
|
|
239286
|
+
const afCurrent = this.config.get("deviceCache");
|
|
239287
|
+
await this.config.setAll({ deviceCache: {
|
|
239288
|
+
...afCurrent,
|
|
239289
|
+
autoFocusSnapshot: {
|
|
239290
|
+
enabled: typeof a.disable === "number" ? a.disable === 0 : null,
|
|
239291
|
+
supported: true
|
|
239292
|
+
},
|
|
239293
|
+
snapshotFetchedAt: stampSnapshotFreshness(afCurrent?.snapshotFetchedAt, ["autoFocusSnapshot"], Date.now())
|
|
239294
|
+
} });
|
|
239295
|
+
}
|
|
238266
239296
|
} catch {}
|
|
238267
239297
|
}
|
|
238268
239298
|
};
|
|
@@ -239627,13 +240657,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
239627
240657
|
const imgSnap = cache?.imageSnapshot ?? {};
|
|
239628
240658
|
const netSnap = cache?.netPortSnapshot ?? {};
|
|
239629
240659
|
const ntpSnap = cache?.ntpSnapshot ?? {};
|
|
240660
|
+
let kickedFullSnapshotRefresh = false;
|
|
239630
240661
|
if (this.hasIncompleteSettingsCache()) {
|
|
239631
240662
|
const now = Date.now();
|
|
239632
240663
|
if (now - this.lastSettingsSnapshotRetryAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
|
|
239633
240664
|
this.lastSettingsSnapshotRetryAt = now;
|
|
240665
|
+
kickedFullSnapshotRefresh = true;
|
|
239634
240666
|
this.refreshParentSettingsSnapshot().catch(() => {});
|
|
239635
240667
|
}
|
|
239636
240668
|
}
|
|
240669
|
+
const osdSnap = cache?.osdSnapshot;
|
|
240670
|
+
if (!kickedFullSnapshotRefresh && isOsdSnapshotStale(osdSnap, Date.now())) {
|
|
240671
|
+
const now = Date.now();
|
|
240672
|
+
if (now - this.lastOsdRevalidateKickAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
|
|
240673
|
+
this.lastOsdRevalidateKickAt = now;
|
|
240674
|
+
this.refreshParentSettingsSnapshot(new Set(["osd"])).catch(() => {});
|
|
240675
|
+
}
|
|
240676
|
+
}
|
|
240677
|
+
const osdValues = resolveOsdValues(osdSnap);
|
|
239637
240678
|
const sessSnap = this.sessionsSnapshot;
|
|
239638
240679
|
const sessStale = sessSnap === null || Date.now() - sessSnap.ts > 6e4;
|
|
239639
240680
|
if (!this.isBattery && sessStale) this.refreshSessionsSnapshot().catch((err) => {
|
|
@@ -240180,45 +241221,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240180
241221
|
}] : []
|
|
240181
241222
|
]
|
|
240182
241223
|
},
|
|
240183
|
-
{
|
|
240184
|
-
|
|
240185
|
-
|
|
240186
|
-
|
|
240187
|
-
description: "On-screen overlays burned into the video by the camera. Pushed via `SetOsd` (cmd_id 25). Overlay positions are kept as configured on the camera — only the toggles, channel-name text, and watermark change here.",
|
|
240188
|
-
columns: 2,
|
|
240189
|
-
fields: [
|
|
240190
|
-
{
|
|
240191
|
-
type: "boolean",
|
|
240192
|
-
key: "osdChannelEnabled",
|
|
240193
|
-
label: "Channel name overlay",
|
|
240194
|
-
default: cache?.osdSnapshot?.channelEnabled ?? true,
|
|
240195
|
-
style: "switch"
|
|
240196
|
-
},
|
|
240197
|
-
{
|
|
240198
|
-
type: "text",
|
|
240199
|
-
key: "osdChannelName",
|
|
240200
|
-
label: "Channel name",
|
|
240201
|
-
description: "Text shown in the channel-name overlay.",
|
|
240202
|
-
default: cache?.osdSnapshot?.channelName ?? "",
|
|
240203
|
-
placeholder: "Front door"
|
|
240204
|
-
},
|
|
240205
|
-
{
|
|
240206
|
-
type: "boolean",
|
|
240207
|
-
key: "osdTimeEnabled",
|
|
240208
|
-
label: "Timestamp overlay",
|
|
240209
|
-
default: cache?.osdSnapshot?.timeEnabled ?? true,
|
|
240210
|
-
style: "switch"
|
|
240211
|
-
},
|
|
240212
|
-
{
|
|
240213
|
-
type: "boolean",
|
|
240214
|
-
key: "osdWatermark",
|
|
240215
|
-
label: "Watermark",
|
|
240216
|
-
description: "The Reolink logo watermark overlay.",
|
|
240217
|
-
default: cache?.osdSnapshot?.watermark ?? false,
|
|
240218
|
-
style: "switch"
|
|
240219
|
-
}
|
|
240220
|
-
]
|
|
240221
|
-
},
|
|
241224
|
+
buildOsdSection(osdSnap, osdValues, {
|
|
241225
|
+
sleeping: this.isBattery && this.sleeping,
|
|
241226
|
+
now: Date.now()
|
|
241227
|
+
}),
|
|
240222
241228
|
{
|
|
240223
241229
|
id: "privacy-mask",
|
|
240224
241230
|
tab: "image",
|
|
@@ -240508,6 +241514,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240508
241514
|
]
|
|
240509
241515
|
}]
|
|
240510
241516
|
},
|
|
241517
|
+
buildSnapshotFreshnessSection(cache, {
|
|
241518
|
+
sleeping: this.isBattery && this.sleeping,
|
|
241519
|
+
now: Date.now()
|
|
241520
|
+
}),
|
|
240511
241521
|
...this.buildSessionsTabSections(),
|
|
240512
241522
|
...this.buildEmailPushSection(),
|
|
240513
241523
|
...this.buildEmailTabSections()
|
|
@@ -240574,10 +241584,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240574
241584
|
irLightsBrightness: this.config.get("irLightsBrightness") ?? 128,
|
|
240575
241585
|
statusLedEnabled: this.config.get("statusLedEnabled") ?? cache?.ledSnapshot?.statusEnabled ?? true,
|
|
240576
241586
|
doorbellLedEnabled: this.config.get("doorbellLedEnabled") ?? cache?.ledSnapshot?.doorbellEnabled ?? true,
|
|
240577
|
-
osdChannelEnabled:
|
|
240578
|
-
osdChannelName:
|
|
240579
|
-
osdTimeEnabled:
|
|
240580
|
-
osdWatermark:
|
|
241587
|
+
osdChannelEnabled: osdValues.osdChannelEnabled,
|
|
241588
|
+
osdChannelName: osdValues.osdChannelName,
|
|
241589
|
+
osdTimeEnabled: osdValues.osdTimeEnabled,
|
|
241590
|
+
osdWatermark: osdValues.osdWatermark,
|
|
240581
241591
|
audioVolume: this.config.get("audioVolume") ?? 50,
|
|
240582
241592
|
audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
|
|
240583
241593
|
audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
|
|
@@ -240596,7 +241606,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240596
241606
|
});
|
|
240597
241607
|
}
|
|
240598
241608
|
async applySettingsPatch(patch) {
|
|
240599
|
-
const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, ...rest } = patch;
|
|
241609
|
+
const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdTimeEnabled, osdWatermark, ...rest } = patch;
|
|
240600
241610
|
const emailFields = {
|
|
240601
241611
|
emailSmtpServer,
|
|
240602
241612
|
emailSmtpPort,
|
|
@@ -240615,8 +241625,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240615
241625
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
240616
241626
|
});
|
|
240617
241627
|
});
|
|
240618
|
-
|
|
240619
|
-
|
|
241628
|
+
const hasOsdPatch = [
|
|
241629
|
+
osdChannelEnabled,
|
|
241630
|
+
osdChannelName,
|
|
241631
|
+
osdTimeEnabled,
|
|
241632
|
+
osdWatermark
|
|
241633
|
+
].some((v) => v !== void 0);
|
|
241634
|
+
if (Object.keys(rest).length === 0 && !hasOsdPatch) return;
|
|
241635
|
+
if (Object.keys(rest).length > 0) await this.config.setAll(rest);
|
|
240620
241636
|
const typedPatch = patch;
|
|
240621
241637
|
if (typedPatch.host || typedPatch.port || typedPatch.username || typedPatch.password) {
|
|
240622
241638
|
await this.disconnectAll();
|
|
@@ -240809,12 +241825,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240809
241825
|
} catch (err) {
|
|
240810
241826
|
this.ctx.logger.warn("ir-lights push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
240811
241827
|
}
|
|
240812
|
-
if (
|
|
240813
|
-
"osdChannelEnabled",
|
|
240814
|
-
"osdChannelName",
|
|
240815
|
-
"osdTimeEnabled",
|
|
240816
|
-
"osdWatermark"
|
|
240817
|
-
].some((k) => k in patch)) try {
|
|
241828
|
+
if (hasOsdPatch) try {
|
|
240818
241829
|
const api = await this.ensureApi();
|
|
240819
241830
|
const channel = this.getChannel();
|
|
240820
241831
|
const current = await api.getOsd(channel);
|
|
@@ -240832,13 +241843,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240832
241843
|
watermark: current.watermark ?? 0
|
|
240833
241844
|
};
|
|
240834
241845
|
if (current.bgcolor !== void 0) next.bgcolor = current.bgcolor;
|
|
240835
|
-
if (typeof
|
|
240836
|
-
if (typeof
|
|
240837
|
-
if (typeof
|
|
240838
|
-
if (typeof
|
|
241846
|
+
if (typeof osdChannelEnabled === "boolean") next.osdChannel.enable = osdChannelEnabled ? 1 : 0;
|
|
241847
|
+
if (typeof osdChannelName === "string") next.osdChannel.name = osdChannelName;
|
|
241848
|
+
if (typeof osdTimeEnabled === "boolean") next.osdTime.enable = osdTimeEnabled ? 1 : 0;
|
|
241849
|
+
if (typeof osdWatermark === "boolean") next.watermark = osdWatermark ? 1 : 0;
|
|
240839
241850
|
await api.setOsd(channel, next);
|
|
240840
241851
|
} catch (err) {
|
|
240841
|
-
this.ctx.logger.warn("osd push failed
|
|
241852
|
+
this.ctx.logger.warn("osd push failed — camera keeps its current overlay state", {
|
|
241853
|
+
tags: { deviceId: this.id },
|
|
241854
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
241855
|
+
});
|
|
240842
241856
|
}
|
|
240843
241857
|
if ([
|
|
240844
241858
|
"audioVolume",
|
|
@@ -240979,7 +241993,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240979
241993
|
}
|
|
240980
241994
|
const changedSlices = slicesForPatch(patch);
|
|
240981
241995
|
if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
|
|
240982
|
-
this.ctx.logger.
|
|
241996
|
+
this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
|
|
241997
|
+
tags: { deviceId: this.id },
|
|
241998
|
+
meta: {
|
|
241999
|
+
slices: [...changedSlices],
|
|
242000
|
+
error: err instanceof Error ? err.message : String(err)
|
|
242001
|
+
}
|
|
242002
|
+
});
|
|
240983
242003
|
});
|
|
240984
242004
|
}
|
|
240985
242005
|
/**
|
|
@@ -243582,6 +244602,44 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
243582
244602
|
return regs;
|
|
243583
244603
|
}
|
|
243584
244604
|
/**
|
|
244605
|
+
* Compose the provider registrations from `onInitialize()` with this
|
|
244606
|
+
* addon's customActions catalog. `BaseDeviceProvider.onInitialize` is
|
|
244607
|
+
* typed `ProviderRegistration[]` (eleven sibling providers push onto it),
|
|
244608
|
+
* so the catalog joins at the `initialize()` seam instead — the runner
|
|
244609
|
+
* consumes the merged `AddonInitResult` exactly as it does for
|
|
244610
|
+
* addon-benchmark / addon-notifiers.
|
|
244611
|
+
*
|
|
244612
|
+
* Admin-only debug surface (D346): `debugRawRead` returns the lib's
|
|
244613
|
+
* response for one snapshot slice UNPROJECTED, next to the persisted
|
|
244614
|
+
* snapshot + its freshness. Registered as an addon customAction because
|
|
244615
|
+
* `addons.custom` is the one operator surface that enforces the
|
|
244616
|
+
* per-action `auth: 'admin'` server-side and validates output —
|
|
244617
|
+
* `deviceManager.runDeviceAction` does neither. The hub reads the static
|
|
244618
|
+
* catalog from this bundle's `customActions` export (see `index.ts`);
|
|
244619
|
+
* the child registers the handlers returned here.
|
|
244620
|
+
*/
|
|
244621
|
+
async initialize(context) {
|
|
244622
|
+
const base = await super.initialize(context);
|
|
244623
|
+
return {
|
|
244624
|
+
providers: base && base.providers ? base.providers : [],
|
|
244625
|
+
customActions: reolinkDebugActions,
|
|
244626
|
+
actionHandlers: { debugRawRead: (input) => this.debugRawRead(input) }
|
|
244627
|
+
};
|
|
244628
|
+
}
|
|
244629
|
+
/**
|
|
244630
|
+
* Route a `debugRawRead` custom action to the owning camera. Covers both
|
|
244631
|
+
* standalone cameras and NVR-adopted children — every live ReolinkCamera
|
|
244632
|
+
* in this runner is in the kernel device registry. Read-only end to end
|
|
244633
|
+
* (see `raw-read.ts`); a hub device or an unknown id refuses with a
|
|
244634
|
+
* message that names what it looked for.
|
|
244635
|
+
*/
|
|
244636
|
+
async debugRawRead(input) {
|
|
244637
|
+
const dev = this.ctx.kernel.deviceRegistry?.getById(input.deviceId);
|
|
244638
|
+
if (dev === void 0 || dev === null) throw new Error(`debugRawRead: device ${input.deviceId} not found in the reolink runner's registry`);
|
|
244639
|
+
if (!(dev instanceof ReolinkCamera)) throw new Error(`debugRawRead: device ${input.deviceId} is not a ReolinkCamera (got ${dev.constructor.name}) — raw reads target cameras, not hubs/accessories`);
|
|
244640
|
+
return dev.debugRawRead(input.slice);
|
|
244641
|
+
}
|
|
244642
|
+
/**
|
|
243585
244643
|
* Handle a broker-issued source-refresh request. With the lazy-publish
|
|
243586
244644
|
* model the broker always emits this on first dial of a
|
|
243587
244645
|
* `lazy:rfc4571:` placeholder URL — and re-emits it whenever the
|
|
@@ -243867,6 +244925,7 @@ exports.collectNativeDiagnostics = collectNativeDiagnostics;
|
|
|
243867
244925
|
exports.collectNvrDiagnostics = collectNvrDiagnostics;
|
|
243868
244926
|
exports.createDiagnosticsBundle = createDiagnosticsBundle;
|
|
243869
244927
|
exports.reolinkCameraSchema = reolinkCameraSchema;
|
|
244928
|
+
exports.reolinkDebugActions = reolinkDebugActions;
|
|
243870
244929
|
exports.runAllDiagnosticsConsecutively = runAllDiagnosticsConsecutively;
|
|
243871
244930
|
exports.runMultifocalDiagnosticsConsecutively = runMultifocalDiagnosticsConsecutively;
|
|
243872
244931
|
exports.sampleStreams = sampleStreams;
|