@camstack/addon-provider-reolink 1.2.81 → 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 +1300 -124
- package/dist/addon.mjs +1302 -127
- 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.mjs
CHANGED
|
@@ -7194,7 +7194,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7194
7194
|
* still gives the event loop a chance to drain — useful for breaking
|
|
7195
7195
|
* up tight async loops without changing call-site semantics.
|
|
7196
7196
|
*/
|
|
7197
|
-
function sleep$
|
|
7197
|
+
function sleep$2(ms) {
|
|
7198
7198
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
7199
7199
|
}
|
|
7200
7200
|
var EncodeProfileSchema = object({
|
|
@@ -11093,6 +11093,89 @@ var LocationStatSchema = object({
|
|
|
11093
11093
|
fileCount: number(),
|
|
11094
11094
|
present: boolean()
|
|
11095
11095
|
});
|
|
11096
|
+
/** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
|
|
11097
|
+
var BackupRunStateSchema = _enum([
|
|
11098
|
+
"queued",
|
|
11099
|
+
"running",
|
|
11100
|
+
"succeeded",
|
|
11101
|
+
"failed",
|
|
11102
|
+
"cancelled"
|
|
11103
|
+
]);
|
|
11104
|
+
/**
|
|
11105
|
+
* Where a running backup currently is. `queued` before it starts,
|
|
11106
|
+
* `building` while the tar.gz is being staged, `uploading` during the
|
|
11107
|
+
* per-destination fan-out, `done` once terminal.
|
|
11108
|
+
*/
|
|
11109
|
+
var BackupRunPhaseSchema = _enum([
|
|
11110
|
+
"queued",
|
|
11111
|
+
"building",
|
|
11112
|
+
"uploading",
|
|
11113
|
+
"done"
|
|
11114
|
+
]);
|
|
11115
|
+
/**
|
|
11116
|
+
* Observable state of one backup run — readable WHILE it runs via
|
|
11117
|
+
* `backup.listRuns`. This is what makes the execution queue and
|
|
11118
|
+
* `backup.cancel` usable: the 2026-09-04 incident (two concurrent
|
|
11119
|
+
* multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
|
|
11120
|
+
* diagnosable with `du` because nothing reported that runs existed or
|
|
11121
|
+
* how large the staged archive had grown.
|
|
11122
|
+
*/
|
|
11123
|
+
var BackupRunSchema = object({
|
|
11124
|
+
/** Stable run id — the handle `backup.cancel` takes. */
|
|
11125
|
+
id: string(),
|
|
11126
|
+
state: BackupRunStateSchema,
|
|
11127
|
+
phase: BackupRunPhaseSchema,
|
|
11128
|
+
/**
|
|
11129
|
+
* Resolved destination location ids. Empty while queued (targets are
|
|
11130
|
+
* resolved when the run starts, against the then-current policies).
|
|
11131
|
+
*/
|
|
11132
|
+
destinationIds: array(string()).readonly(),
|
|
11133
|
+
label: string().optional(),
|
|
11134
|
+
/** ms-epoch when the run was submitted (trigger call / schedule fire). */
|
|
11135
|
+
requestedAt: number(),
|
|
11136
|
+
/** ms-epoch when the run left the queue and started building. */
|
|
11137
|
+
startedAt: number().optional(),
|
|
11138
|
+
/** ms-epoch when the run reached a terminal state. */
|
|
11139
|
+
finishedAt: number().optional(),
|
|
11140
|
+
/** Compressed bytes of the staging archive written so far. */
|
|
11141
|
+
stagedBytes: number(),
|
|
11142
|
+
/** Final staged archive size, once the build phase completes. */
|
|
11143
|
+
archiveSizeBytes: number().optional(),
|
|
11144
|
+
/** Bytes pushed to the destination currently uploading. */
|
|
11145
|
+
uploadedBytes: number(),
|
|
11146
|
+
/** Destinations where the archive fully landed (uploaded + indexed). */
|
|
11147
|
+
completedDestinationIds: array(string()).readonly(),
|
|
11148
|
+
/** Destinations that failed during the fan-out. */
|
|
11149
|
+
failedDestinationIds: array(string()).readonly(),
|
|
11150
|
+
/** Failure message when `state === 'failed'`. */
|
|
11151
|
+
error: string().optional(),
|
|
11152
|
+
/**
|
|
11153
|
+
* 1-based place in the execution queue — 1 = runs next. Present only
|
|
11154
|
+
* while `state === 'queued'`. Stamped by the orchestrator from the
|
|
11155
|
+
* queue's OWN pending order, never derived from timestamps, so the
|
|
11156
|
+
* UI cannot show an order the executor will not honour.
|
|
11157
|
+
*/
|
|
11158
|
+
queuePosition: number().int().min(1).optional()
|
|
11159
|
+
});
|
|
11160
|
+
/**
|
|
11161
|
+
* Result of `backup.trigger`. The call still resolves when the run
|
|
11162
|
+
* terminates (compat with schedule-driven runs and the admin UI), but
|
|
11163
|
+
* it now names the run and says whether it had to WAIT: a trigger that
|
|
11164
|
+
* arrives while another run is in flight is enqueued (or joined onto
|
|
11165
|
+
* an identical already-queued run), never started concurrently.
|
|
11166
|
+
*/
|
|
11167
|
+
var BackupTriggerResultSchema = object({
|
|
11168
|
+
/** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
|
|
11169
|
+
runId: string(),
|
|
11170
|
+
/** True when the run waited behind an in-flight run instead of starting immediately. */
|
|
11171
|
+
queued: boolean(),
|
|
11172
|
+
/** True when this trigger was coalesced onto an identical already-queued run. */
|
|
11173
|
+
joined: boolean(),
|
|
11174
|
+
/** True when the run was cancelled before completing every destination. */
|
|
11175
|
+
cancelled: boolean(),
|
|
11176
|
+
/** One entry per destination the archive landed at (partial on cancel). */
|
|
11177
|
+
entries: array(BackupEntrySchema).readonly()
|
|
11178
|
+
});
|
|
11096
11179
|
/**
|
|
11097
11180
|
* A backup schedule — the N:M "entry" that binds one cron cadence to a
|
|
11098
11181
|
* SET of destination locations. Supersedes the per-location cron on
|
|
@@ -11140,7 +11223,10 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
|
|
|
11140
11223
|
* retention (manual runs).
|
|
11141
11224
|
*/
|
|
11142
11225
|
retentionCount: number().int().min(1).max(1e3).optional()
|
|
11143
|
-
}).optional(),
|
|
11226
|
+
}).optional(), BackupTriggerResultSchema, {
|
|
11227
|
+
kind: "mutation",
|
|
11228
|
+
auth: "admin"
|
|
11229
|
+
}), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
|
|
11144
11230
|
kind: "mutation",
|
|
11145
11231
|
auth: "admin"
|
|
11146
11232
|
}), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
|
|
@@ -11857,6 +11943,14 @@ method(object({
|
|
|
11857
11943
|
}), object({ success: literal(true) }), {
|
|
11858
11944
|
kind: "mutation",
|
|
11859
11945
|
auth: "admin"
|
|
11946
|
+
}), method(object({ deviceId: number().int().nonnegative() }), object({
|
|
11947
|
+
derivedStreamsDeleted: array(string()).readonly(),
|
|
11948
|
+
assignmentsPurged: boolean(),
|
|
11949
|
+
probeSnapshotsDropped: number().int().nonnegative(),
|
|
11950
|
+
rtspTokenRowsDeleted: number().int().nonnegative()
|
|
11951
|
+
}), {
|
|
11952
|
+
kind: "mutation",
|
|
11953
|
+
auth: "admin"
|
|
11860
11954
|
}), method(object({
|
|
11861
11955
|
deviceId: number(),
|
|
11862
11956
|
/** Absent = the LOWEST assigned profile — a notification attachment is
|
|
@@ -12312,6 +12406,28 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
12312
12406
|
filePath: string(),
|
|
12313
12407
|
content: string()
|
|
12314
12408
|
})) }), { auth: "admin" });
|
|
12409
|
+
/**
|
|
12410
|
+
* Identity — preserves literal types for downstream inference.
|
|
12411
|
+
*
|
|
12412
|
+
* The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
|
|
12413
|
+
* TypeScript does not widen each entry's literal `kind`/`auth` fields to
|
|
12414
|
+
* the broader unions declared on `CustomActionSpec`'s default generics.
|
|
12415
|
+
* Shape validity is enforced separately by the `customAction(...)` helper
|
|
12416
|
+
* whose return type is already a `CustomActionSpec<...>`.
|
|
12417
|
+
*/
|
|
12418
|
+
function defineCustomActions(spec) {
|
|
12419
|
+
return spec;
|
|
12420
|
+
}
|
|
12421
|
+
function customAction(input, output, options) {
|
|
12422
|
+
return {
|
|
12423
|
+
input,
|
|
12424
|
+
output,
|
|
12425
|
+
kind: options?.kind ?? "query",
|
|
12426
|
+
auth: options?.auth ?? "protected",
|
|
12427
|
+
scope: options?.scope ?? { kind: "system" },
|
|
12428
|
+
...options?.caller ? { caller: "required" } : {}
|
|
12429
|
+
};
|
|
12430
|
+
}
|
|
12315
12431
|
function deviceCustomAction(input, output, options) {
|
|
12316
12432
|
return {
|
|
12317
12433
|
input,
|
|
@@ -13296,6 +13412,35 @@ var deviceProviderCapability = {
|
|
|
13296
13412
|
name: string(),
|
|
13297
13413
|
type: string()
|
|
13298
13414
|
}))),
|
|
13415
|
+
/**
|
|
13416
|
+
* Tear down and reconstruct ONE device in place from its persisted rows —
|
|
13417
|
+
* touching no other device this provider owns.
|
|
13418
|
+
*
|
|
13419
|
+
* The primitive `deviceManager.migrateDevice` uses to flush the two
|
|
13420
|
+
* migrated numbers: after `swapIds` the runner's live instance still
|
|
13421
|
+
* carries the PRE-swap numeric id (baked into the object, its native-cap
|
|
13422
|
+
* registrations and its log tags), and a live object cannot be renumbered.
|
|
13423
|
+
* Before this method the only flush was restarting the whole owning addon
|
|
13424
|
+
* — which took every camera the provider owns down with it (28 devices
|
|
13425
|
+
* for one migrated camera, measured 2026-09-04, and the morning of the
|
|
13426
|
+
* same day ~27 devices' native caps did not come back on their own).
|
|
13427
|
+
*
|
|
13428
|
+
* Keyed by `stableId`, deliberately: the numeric id is exactly the thing
|
|
13429
|
+
* that changes. The reply carries the id the device answers on NOW.
|
|
13430
|
+
* Implemented once in `BaseDeviceProvider` — decommission the live
|
|
13431
|
+
* instance (if any), then re-create from the persisted row: the same
|
|
13432
|
+
* teardown/rehydrate pair every graceful shutdown + boot already uses.
|
|
13433
|
+
* An RPC, never an event: a dropped event would leave the runner writing
|
|
13434
|
+
* against the wrong camera (D8).
|
|
13435
|
+
*
|
|
13436
|
+
* Construction can dial hardware, and the migrated source is
|
|
13437
|
+
* characteristically dead — the timeout covers a full activate window
|
|
13438
|
+
* rather than the 60 s default.
|
|
13439
|
+
*/
|
|
13440
|
+
reloadDevice: method(object({ stableId: string() }), object({ deviceId: number() }), {
|
|
13441
|
+
kind: "mutation",
|
|
13442
|
+
timeoutMs: 3 * 6e4
|
|
13443
|
+
}),
|
|
13299
13444
|
supportsDiscovery: method(object({}), boolean()),
|
|
13300
13445
|
/**
|
|
13301
13446
|
* Run a network scan. `params` carries optional provider-specific scan
|
|
@@ -13623,7 +13768,8 @@ method(object({
|
|
|
13623
13768
|
targetId: number()
|
|
13624
13769
|
}), MigrateDeviceResultSchema, {
|
|
13625
13770
|
kind: "mutation",
|
|
13626
|
-
auth: "admin"
|
|
13771
|
+
auth: "admin",
|
|
13772
|
+
timeoutMs: 12 * 6e4
|
|
13627
13773
|
}), 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({
|
|
13628
13774
|
deviceId: number(),
|
|
13629
13775
|
name: string()
|
|
@@ -33423,6 +33569,147 @@ var BaseDevice = class {
|
|
|
33423
33569
|
}
|
|
33424
33570
|
};
|
|
33425
33571
|
/**
|
|
33572
|
+
* Delays before retry rounds 1..N — the round count IS the bound.
|
|
33573
|
+
* 10 s catches "the hub was busy for a moment"; the full schedule
|
|
33574
|
+
* (10 + 30 + 90 s of waiting, plus up to one 60 s transport timeout
|
|
33575
|
+
* per attempt) covers a device-manager lock held for minutes — the
|
|
33576
|
+
* 2026-09-04 outage's migration hold was ~3.5 min.
|
|
33577
|
+
*/
|
|
33578
|
+
var DEVICE_RESTORE_RETRY_DELAYS_MS = [
|
|
33579
|
+
1e4,
|
|
33580
|
+
3e4,
|
|
33581
|
+
9e4
|
|
33582
|
+
];
|
|
33583
|
+
/** Abortable sleep — resolves early (never rejects) on abort. */
|
|
33584
|
+
function sleep$1(ms, signal) {
|
|
33585
|
+
return new Promise((resolve) => {
|
|
33586
|
+
if (signal.aborted) {
|
|
33587
|
+
resolve();
|
|
33588
|
+
return;
|
|
33589
|
+
}
|
|
33590
|
+
const onAbort = () => {
|
|
33591
|
+
clearTimeout(timer);
|
|
33592
|
+
resolve();
|
|
33593
|
+
};
|
|
33594
|
+
const timer = setTimeout(() => {
|
|
33595
|
+
signal.removeEventListener("abort", onAbort);
|
|
33596
|
+
resolve();
|
|
33597
|
+
}, ms);
|
|
33598
|
+
timer.unref?.();
|
|
33599
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
33600
|
+
});
|
|
33601
|
+
}
|
|
33602
|
+
/** Drain `items` through at most `width` concurrent lanes. `fn` must
|
|
33603
|
+
* not reject (callers wrap their own try/catch). */
|
|
33604
|
+
async function runWithConcurrency(items, width, fn) {
|
|
33605
|
+
const queue = [...items];
|
|
33606
|
+
const laneCount = Math.max(1, Math.min(width, queue.length));
|
|
33607
|
+
const lane = async () => {
|
|
33608
|
+
for (;;) {
|
|
33609
|
+
const item = queue.shift();
|
|
33610
|
+
if (item === void 0) return;
|
|
33611
|
+
await fn(item);
|
|
33612
|
+
}
|
|
33613
|
+
};
|
|
33614
|
+
await Promise.all(Array.from({ length: laneCount }, lane));
|
|
33615
|
+
}
|
|
33616
|
+
var DeviceRestoreRetryScheduler = class {
|
|
33617
|
+
#logger;
|
|
33618
|
+
#attempt;
|
|
33619
|
+
#onPermanentFailure;
|
|
33620
|
+
#delaysMs;
|
|
33621
|
+
#concurrency;
|
|
33622
|
+
#now;
|
|
33623
|
+
#abort = new AbortController();
|
|
33624
|
+
constructor(options) {
|
|
33625
|
+
this.#logger = options.logger;
|
|
33626
|
+
this.#attempt = options.attempt;
|
|
33627
|
+
this.#onPermanentFailure = options.onPermanentFailure;
|
|
33628
|
+
this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
33629
|
+
this.#concurrency = options.concurrency ?? 4;
|
|
33630
|
+
this.#now = options.now ?? Date.now;
|
|
33631
|
+
}
|
|
33632
|
+
/** Stop retrying (shutdown). Pending entries are NOT marked
|
|
33633
|
+
* permanently failed — the next boot restores them from disk. */
|
|
33634
|
+
cancel() {
|
|
33635
|
+
this.#abort.abort();
|
|
33636
|
+
}
|
|
33637
|
+
/**
|
|
33638
|
+
* Run the bounded retry rounds. Resolves when every entry has either
|
|
33639
|
+
* restored, been marked permanently failed, or the scheduler was
|
|
33640
|
+
* cancelled. Never rejects.
|
|
33641
|
+
*/
|
|
33642
|
+
async run(initialFailures) {
|
|
33643
|
+
let pending = initialFailures.map((failure) => ({
|
|
33644
|
+
saved: failure.saved,
|
|
33645
|
+
lastError: failure.error,
|
|
33646
|
+
attempts: 1
|
|
33647
|
+
}));
|
|
33648
|
+
for (let round = 0; round < this.#delaysMs.length; round += 1) {
|
|
33649
|
+
if (pending.length === 0 || this.#abort.signal.aborted) break;
|
|
33650
|
+
await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
|
|
33651
|
+
if (this.#abort.signal.aborted) break;
|
|
33652
|
+
pending = await this.#runRound(pending, round);
|
|
33653
|
+
}
|
|
33654
|
+
if (this.#abort.signal.aborted) return [];
|
|
33655
|
+
const terminal = pending.map((entry) => ({
|
|
33656
|
+
deviceId: entry.saved.id,
|
|
33657
|
+
stableId: entry.saved.stableId,
|
|
33658
|
+
type: String(entry.saved.type),
|
|
33659
|
+
attempts: entry.attempts,
|
|
33660
|
+
lastError: entry.lastError,
|
|
33661
|
+
failedAt: this.#now()
|
|
33662
|
+
}));
|
|
33663
|
+
for (const failure of terminal) this.#onPermanentFailure(failure);
|
|
33664
|
+
return terminal;
|
|
33665
|
+
}
|
|
33666
|
+
/** One retry round: parents first (phase 0), then hub-adopted
|
|
33667
|
+
* children (phase 1) — a child's attempt depends on its parent
|
|
33668
|
+
* having landed, exactly like the initial two-pass restore. */
|
|
33669
|
+
async #runRound(pending, round) {
|
|
33670
|
+
const next = [];
|
|
33671
|
+
const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
|
|
33672
|
+
const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
|
|
33673
|
+
for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
|
|
33674
|
+
if (this.#abort.signal.aborted) {
|
|
33675
|
+
next.push(entry);
|
|
33676
|
+
return;
|
|
33677
|
+
}
|
|
33678
|
+
const attemptNo = entry.attempts + 1;
|
|
33679
|
+
try {
|
|
33680
|
+
await this.#attempt(entry.saved);
|
|
33681
|
+
this.#logger.info("Device restored on retry", {
|
|
33682
|
+
tags: {
|
|
33683
|
+
deviceId: entry.saved.id,
|
|
33684
|
+
stableId: entry.saved.stableId
|
|
33685
|
+
},
|
|
33686
|
+
meta: { attempt: attemptNo }
|
|
33687
|
+
});
|
|
33688
|
+
} catch (err) {
|
|
33689
|
+
const lastError = err instanceof Error ? err.message : String(err);
|
|
33690
|
+
const remainingRetries = this.#delaysMs.length - (round + 1);
|
|
33691
|
+
this.#logger.warn("Device restore retry failed", {
|
|
33692
|
+
tags: {
|
|
33693
|
+
deviceId: entry.saved.id,
|
|
33694
|
+
stableId: entry.saved.stableId
|
|
33695
|
+
},
|
|
33696
|
+
meta: {
|
|
33697
|
+
attempt: attemptNo,
|
|
33698
|
+
remainingRetries,
|
|
33699
|
+
error: lastError
|
|
33700
|
+
}
|
|
33701
|
+
});
|
|
33702
|
+
next.push({
|
|
33703
|
+
saved: entry.saved,
|
|
33704
|
+
lastError,
|
|
33705
|
+
attempts: attemptNo
|
|
33706
|
+
});
|
|
33707
|
+
}
|
|
33708
|
+
});
|
|
33709
|
+
return next;
|
|
33710
|
+
}
|
|
33711
|
+
};
|
|
33712
|
+
/**
|
|
33426
33713
|
* Convert an IDevice to the flat DeviceSummary shape expected by the
|
|
33427
33714
|
* device-provider cap router. Shared across all providers.
|
|
33428
33715
|
*/
|
|
@@ -33471,6 +33758,7 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33471
33758
|
}];
|
|
33472
33759
|
}
|
|
33473
33760
|
async onShutdown() {
|
|
33761
|
+
this.cancelRestoreRetries();
|
|
33474
33762
|
const devices = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
33475
33763
|
for (const device of devices) try {
|
|
33476
33764
|
await this.ctx.kernel.devices?.decommission(device.id);
|
|
@@ -33488,9 +33776,16 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33488
33776
|
async start() {}
|
|
33489
33777
|
async stop() {}
|
|
33490
33778
|
async getStatus() {
|
|
33779
|
+
const all = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
33780
|
+
const summary = this.restoreFailureSummary();
|
|
33781
|
+
if (summary === null) return {
|
|
33782
|
+
connected: true,
|
|
33783
|
+
deviceCount: all.length
|
|
33784
|
+
};
|
|
33491
33785
|
return {
|
|
33492
33786
|
connected: true,
|
|
33493
|
-
deviceCount:
|
|
33787
|
+
deviceCount: all.length,
|
|
33788
|
+
error: summary
|
|
33494
33789
|
};
|
|
33495
33790
|
}
|
|
33496
33791
|
async getDevices() {
|
|
@@ -33580,8 +33875,137 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33580
33875
|
};
|
|
33581
33876
|
}
|
|
33582
33877
|
async restoreDevices(savedDevices) {
|
|
33583
|
-
await this.onRestoreDevices(savedDevices);
|
|
33584
|
-
if (savedDevices.length
|
|
33878
|
+
const report = await this.onRestoreDevices(savedDevices);
|
|
33879
|
+
if (savedDevices.length === 0) return;
|
|
33880
|
+
if (report && report.failedCount > 0) {
|
|
33881
|
+
this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) — ${report.failedCount} failed, bounded retry scheduled`);
|
|
33882
|
+
return;
|
|
33883
|
+
}
|
|
33884
|
+
const restoredCount = report ? report.restoredCount : savedDevices.length;
|
|
33885
|
+
this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
|
|
33886
|
+
}
|
|
33887
|
+
/** Retry schedule. Overridable (tests use millisecond delays). */
|
|
33888
|
+
restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
|
|
33889
|
+
/** Retry lane width. See `device-restore-retry.ts` for why retries
|
|
33890
|
+
* never re-stampede full-width while the initial pass does (D167). */
|
|
33891
|
+
restoreRetryConcurrency = 4;
|
|
33892
|
+
_restoreRetryScheduler = null;
|
|
33893
|
+
_restoreRetryCompletion = null;
|
|
33894
|
+
_permanentRestoreFailures = /* @__PURE__ */ new Map();
|
|
33895
|
+
/** Settles when the background retry rounds finish (or `null` when
|
|
33896
|
+
* nothing failed). Exposed for tests and subclass diagnostics —
|
|
33897
|
+
* boot NEVER awaits this: the runner's post-init handshake goes out
|
|
33898
|
+
* with the devices that restored, and a late success is announced
|
|
33899
|
+
* through the `native-cap-change` → `updateCaps` path. */
|
|
33900
|
+
get restoreRetryCompletion() {
|
|
33901
|
+
return this._restoreRetryCompletion;
|
|
33902
|
+
}
|
|
33903
|
+
/** Devices that exhausted the retry bound this process lifetime. */
|
|
33904
|
+
get permanentRestoreFailures() {
|
|
33905
|
+
return [...this._permanentRestoreFailures.values()];
|
|
33906
|
+
}
|
|
33907
|
+
/** One-line operator-facing summary for `getStatus().error`, or
|
|
33908
|
+
* `null` when every device restored. */
|
|
33909
|
+
restoreFailureSummary() {
|
|
33910
|
+
if (this._permanentRestoreFailures.size === 0) return null;
|
|
33911
|
+
const ids = [...this._permanentRestoreFailures.keys()].join(", ");
|
|
33912
|
+
return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) — restart the ${this.providerName} provider to retry`;
|
|
33913
|
+
}
|
|
33914
|
+
cancelRestoreRetries() {
|
|
33915
|
+
this._restoreRetryScheduler?.cancel();
|
|
33916
|
+
this._restoreRetryScheduler = null;
|
|
33917
|
+
}
|
|
33918
|
+
recordPermanentRestoreFailure(failure) {
|
|
33919
|
+
this._permanentRestoreFailures.set(failure.deviceId, failure);
|
|
33920
|
+
this.ctx.logger.error("Device restore permanently failed — its capabilities will not register until the provider restarts", {
|
|
33921
|
+
tags: {
|
|
33922
|
+
deviceId: failure.deviceId,
|
|
33923
|
+
stableId: failure.stableId
|
|
33924
|
+
},
|
|
33925
|
+
meta: {
|
|
33926
|
+
type: failure.type,
|
|
33927
|
+
attempts: failure.attempts,
|
|
33928
|
+
error: failure.lastError
|
|
33929
|
+
}
|
|
33930
|
+
});
|
|
33931
|
+
}
|
|
33932
|
+
scheduleRestoreRetries(failures, attempt) {
|
|
33933
|
+
const scheduler = new DeviceRestoreRetryScheduler({
|
|
33934
|
+
logger: this.ctx.logger,
|
|
33935
|
+
delaysMs: this.restoreRetryDelaysMs,
|
|
33936
|
+
concurrency: this.restoreRetryConcurrency,
|
|
33937
|
+
attempt,
|
|
33938
|
+
onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
|
|
33939
|
+
});
|
|
33940
|
+
this._restoreRetryScheduler = scheduler;
|
|
33941
|
+
this._restoreRetryCompletion = scheduler.run(failures).then(() => void 0).catch((err) => {
|
|
33942
|
+
this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
33943
|
+
});
|
|
33944
|
+
}
|
|
33945
|
+
/**
|
|
33946
|
+
* Tear down and reconstruct ONE device from its persisted rows — the
|
|
33947
|
+
* `deviceProvider.reloadDevice` cap method. Persistence is never touched,
|
|
33948
|
+
* and no other device this provider owns is disturbed.
|
|
33949
|
+
*
|
|
33950
|
+
* Keyed by `stableId` because the caller's whole reason to be here is that
|
|
33951
|
+
* the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
|
|
33952
|
+
* fresh instance resolves its id through `allocateDeviceId`, which returns
|
|
33953
|
+
* whatever number the row carries NOW. The teardown is `decommission` —
|
|
33954
|
+
* exactly what a graceful shutdown runs per device (fires `removeDevice()`,
|
|
33955
|
+
* unregisters native caps, drops the registry entry) — and the rebuild is
|
|
33956
|
+
* the boot restore's own `create()` path, including its pass 2: first-class
|
|
33957
|
+
* children (hub-adopted cameras under an NVR) are decommissioned with the
|
|
33958
|
+
* parent by the cascade and must be re-created explicitly, because only
|
|
33959
|
+
* accessory children come back through `getAccessoryChildren()`.
|
|
33960
|
+
*
|
|
33961
|
+
* Reloading an accessory child directly is refused (no device class) —
|
|
33962
|
+
* reload its parent instead.
|
|
33963
|
+
*/
|
|
33964
|
+
async reloadDevice(input) {
|
|
33965
|
+
const { stableId } = input;
|
|
33966
|
+
const devices = this.ctx.kernel.devices;
|
|
33967
|
+
if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable — cannot reload`);
|
|
33968
|
+
const live = (await devices.getAll()).find((d) => d.stableId === stableId);
|
|
33969
|
+
if (live) await devices.decommission(live.id);
|
|
33970
|
+
const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
|
|
33971
|
+
addonId: this.addonId,
|
|
33972
|
+
stableId
|
|
33973
|
+
});
|
|
33974
|
+
const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
|
|
33975
|
+
if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) — cannot reload`);
|
|
33976
|
+
const deviceType = Object.values(DeviceType).find((t) => t === meta.type);
|
|
33977
|
+
const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
|
|
33978
|
+
if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" — "${stableId}" is an accessory child; reload its parent instead`);
|
|
33979
|
+
await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
|
|
33980
|
+
const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
|
|
33981
|
+
for (const row of rows) {
|
|
33982
|
+
if (row.parentDeviceId !== id) continue;
|
|
33983
|
+
const childType = Object.values(DeviceType).find((t) => t === row.type);
|
|
33984
|
+
const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
|
|
33985
|
+
if (!ChildClass) continue;
|
|
33986
|
+
try {
|
|
33987
|
+
await devices.create(row.stableId, ChildClass, {}, id);
|
|
33988
|
+
} catch (err) {
|
|
33989
|
+
this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
|
|
33990
|
+
tags: {
|
|
33991
|
+
deviceId: row.id,
|
|
33992
|
+
stableId: row.stableId
|
|
33993
|
+
},
|
|
33994
|
+
meta: {
|
|
33995
|
+
parentDeviceId: id,
|
|
33996
|
+
error: err instanceof Error ? err.message : String(err)
|
|
33997
|
+
}
|
|
33998
|
+
});
|
|
33999
|
+
}
|
|
34000
|
+
}
|
|
34001
|
+
this.ctx.logger.info("device reloaded in place from persisted rows", {
|
|
34002
|
+
tags: { deviceId: id },
|
|
34003
|
+
meta: {
|
|
34004
|
+
stableId,
|
|
34005
|
+
type: meta.type
|
|
34006
|
+
}
|
|
34007
|
+
});
|
|
34008
|
+
return { deviceId: id };
|
|
33585
34009
|
}
|
|
33586
34010
|
/**
|
|
33587
34011
|
* Restore devices from persisted state. Two-pass:
|
|
@@ -33607,55 +34031,108 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33607
34031
|
* accessory-spawn flow handles via the parent's
|
|
33608
34032
|
* `getAccessoryChildren()`. Override only when the default doesn't
|
|
33609
34033
|
* fit.
|
|
34034
|
+
*
|
|
34035
|
+
* A row that fails either pass is NOT terminal (D347): it is handed
|
|
34036
|
+
* to a bounded background retry (`DeviceRestoreRetryScheduler`).
|
|
34037
|
+
* Only after the bound is exhausted is the device marked permanently
|
|
34038
|
+
* failed — logged at ERROR with `tags.deviceId` and surfaced via
|
|
34039
|
+
* `getStatus().error`.
|
|
33610
34040
|
*/
|
|
33611
34041
|
async onRestoreDevices(savedDevices) {
|
|
33612
34042
|
const restored = /* @__PURE__ */ new Set();
|
|
34043
|
+
const failures = [];
|
|
34044
|
+
const attemptRestore = async (saved) => {
|
|
34045
|
+
if (restored.has(saved.id)) return;
|
|
34046
|
+
const Class = this.deviceClasses[saved.type];
|
|
34047
|
+
if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
|
|
34048
|
+
if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
|
|
34049
|
+
await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
|
|
34050
|
+
restored.add(saved.id);
|
|
34051
|
+
};
|
|
33613
34052
|
const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
|
|
33614
34053
|
const restoreOne = async (saved) => {
|
|
33615
|
-
|
|
33616
|
-
if (!Class) {
|
|
34054
|
+
if (!this.deviceClasses[saved.type]) {
|
|
33617
34055
|
this.ctx.logger.warn("No device class registered for restored type — skipping", {
|
|
33618
|
-
tags: {
|
|
34056
|
+
tags: {
|
|
34057
|
+
deviceId: saved.id,
|
|
34058
|
+
stableId: saved.stableId
|
|
34059
|
+
},
|
|
33619
34060
|
meta: { type: saved.type }
|
|
33620
34061
|
});
|
|
33621
34062
|
return;
|
|
33622
34063
|
}
|
|
33623
34064
|
try {
|
|
33624
|
-
await
|
|
33625
|
-
restored.add(saved.id);
|
|
34065
|
+
await attemptRestore(saved);
|
|
33626
34066
|
} catch (err) {
|
|
33627
|
-
|
|
33628
|
-
|
|
34067
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
34068
|
+
this.ctx.logger.warn("Failed to restore device — bounded retry scheduled", {
|
|
34069
|
+
tags: {
|
|
34070
|
+
deviceId: saved.id,
|
|
34071
|
+
stableId: saved.stableId
|
|
34072
|
+
},
|
|
33629
34073
|
meta: {
|
|
33630
34074
|
type: saved.type,
|
|
33631
|
-
|
|
34075
|
+
attempt: 1,
|
|
34076
|
+
error
|
|
33632
34077
|
}
|
|
33633
34078
|
});
|
|
34079
|
+
failures.push({
|
|
34080
|
+
saved,
|
|
34081
|
+
error
|
|
34082
|
+
});
|
|
33634
34083
|
}
|
|
33635
34084
|
};
|
|
33636
34085
|
await Promise.all(topLevel.map((saved) => restoreOne(saved)));
|
|
34086
|
+
const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
|
|
33637
34087
|
const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
|
|
33638
34088
|
for (const saved of childRows) {
|
|
33639
|
-
|
|
33640
|
-
if (!Class) continue;
|
|
34089
|
+
if (!this.deviceClasses[saved.type]) continue;
|
|
33641
34090
|
if (saved.parentDeviceId === null) continue;
|
|
33642
|
-
if (
|
|
33643
|
-
|
|
33644
|
-
|
|
33645
|
-
|
|
33646
|
-
|
|
33647
|
-
|
|
34091
|
+
if (restored.has(saved.parentDeviceId)) {
|
|
34092
|
+
try {
|
|
34093
|
+
await attemptRestore(saved);
|
|
34094
|
+
} catch (err) {
|
|
34095
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
34096
|
+
this.ctx.logger.warn("Failed to restore hub-adopted child — bounded retry scheduled", {
|
|
34097
|
+
tags: {
|
|
34098
|
+
deviceId: saved.id,
|
|
34099
|
+
stableId: saved.stableId,
|
|
34100
|
+
parentDeviceId: saved.parentDeviceId
|
|
34101
|
+
},
|
|
34102
|
+
meta: {
|
|
34103
|
+
type: saved.type,
|
|
34104
|
+
attempt: 1,
|
|
34105
|
+
error
|
|
34106
|
+
}
|
|
34107
|
+
});
|
|
34108
|
+
failures.push({
|
|
34109
|
+
saved,
|
|
34110
|
+
error
|
|
34111
|
+
});
|
|
34112
|
+
}
|
|
34113
|
+
continue;
|
|
34114
|
+
}
|
|
34115
|
+
if (failedTopLevelIds.has(saved.parentDeviceId)) {
|
|
34116
|
+
this.ctx.logger.warn("Hub-adopted child deferred — parent failed initial restore", {
|
|
33648
34117
|
tags: {
|
|
34118
|
+
deviceId: saved.id,
|
|
33649
34119
|
stableId: saved.stableId,
|
|
33650
34120
|
parentDeviceId: saved.parentDeviceId
|
|
33651
34121
|
},
|
|
33652
|
-
meta: {
|
|
33653
|
-
|
|
33654
|
-
|
|
33655
|
-
|
|
34122
|
+
meta: { type: saved.type }
|
|
34123
|
+
});
|
|
34124
|
+
failures.push({
|
|
34125
|
+
saved,
|
|
34126
|
+
error: `parent device ${saved.parentDeviceId} not restored`
|
|
33656
34127
|
});
|
|
34128
|
+
continue;
|
|
33657
34129
|
}
|
|
33658
34130
|
}
|
|
34131
|
+
if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
|
|
34132
|
+
return {
|
|
34133
|
+
restoredCount: restored.size,
|
|
34134
|
+
failedCount: failures.length
|
|
34135
|
+
};
|
|
33659
34136
|
}
|
|
33660
34137
|
/** Convert an IDevice to the flat DeviceSummary for the cap router. */
|
|
33661
34138
|
toSummary(device) {
|
|
@@ -34316,6 +34793,12 @@ Object.freeze({
|
|
|
34316
34793
|
addonId: null,
|
|
34317
34794
|
access: "create"
|
|
34318
34795
|
},
|
|
34796
|
+
"backup.cancel": {
|
|
34797
|
+
capName: "backup",
|
|
34798
|
+
capScope: "system",
|
|
34799
|
+
addonId: null,
|
|
34800
|
+
access: "create"
|
|
34801
|
+
},
|
|
34319
34802
|
"backup.delete": {
|
|
34320
34803
|
capName: "backup",
|
|
34321
34804
|
capScope: "system",
|
|
@@ -34358,6 +34841,12 @@ Object.freeze({
|
|
|
34358
34841
|
addonId: null,
|
|
34359
34842
|
access: "view"
|
|
34360
34843
|
},
|
|
34844
|
+
"backup.listRuns": {
|
|
34845
|
+
capName: "backup",
|
|
34846
|
+
capScope: "system",
|
|
34847
|
+
addonId: null,
|
|
34848
|
+
access: "view"
|
|
34849
|
+
},
|
|
34361
34850
|
"backup.listSchedules": {
|
|
34362
34851
|
capName: "backup",
|
|
34363
34852
|
capScope: "system",
|
|
@@ -35558,6 +36047,12 @@ Object.freeze({
|
|
|
35558
36047
|
addonId: null,
|
|
35559
36048
|
access: "view"
|
|
35560
36049
|
},
|
|
36050
|
+
"deviceProvider.reloadDevice": {
|
|
36051
|
+
capName: "device-provider",
|
|
36052
|
+
capScope: "system",
|
|
36053
|
+
addonId: null,
|
|
36054
|
+
access: "create"
|
|
36055
|
+
},
|
|
35561
36056
|
"deviceProvider.start": {
|
|
35562
36057
|
capName: "device-provider",
|
|
35563
36058
|
capScope: "system",
|
|
@@ -38924,6 +39419,12 @@ Object.freeze({
|
|
|
38924
39419
|
addonId: null,
|
|
38925
39420
|
access: "create"
|
|
38926
39421
|
},
|
|
39422
|
+
"streamBroker.forgetDeviceHardware": {
|
|
39423
|
+
capName: "stream-broker",
|
|
39424
|
+
capScope: "system",
|
|
39425
|
+
addonId: null,
|
|
39426
|
+
access: "delete"
|
|
39427
|
+
},
|
|
38927
39428
|
"streamBroker.getAllRtspEntries": {
|
|
38928
39429
|
capName: "stream-broker",
|
|
38929
39430
|
capScope: "system",
|
|
@@ -41382,6 +41883,11 @@ Object.freeze({
|
|
|
41382
41883
|
form: "single",
|
|
41383
41884
|
optional: false
|
|
41384
41885
|
}],
|
|
41886
|
+
"streamBroker.forgetDeviceHardware": [{
|
|
41887
|
+
name: "deviceId",
|
|
41888
|
+
form: "single",
|
|
41889
|
+
optional: false
|
|
41890
|
+
}],
|
|
41385
41891
|
"streamBroker.getDeviceAudioMute": [{
|
|
41386
41892
|
name: "deviceId",
|
|
41387
41893
|
form: "single",
|
|
@@ -178971,7 +179477,7 @@ ${xml}`);
|
|
|
178971
179477
|
* @returns Test results for all stream types and profiles
|
|
178972
179478
|
*/
|
|
178973
179479
|
async testChannelStreams(channel, logger) {
|
|
178974
|
-
const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-
|
|
179480
|
+
const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
|
|
178975
179481
|
return await testChannelStreams({
|
|
178976
179482
|
api: this,
|
|
178977
179483
|
channel: this.normalizeChannel(channel),
|
|
@@ -178987,7 +179493,7 @@ ${xml}`);
|
|
|
178987
179493
|
* @returns Complete diagnostics for all channels and streams
|
|
178988
179494
|
*/
|
|
178989
179495
|
async collectMultifocalDiagnostics(logger) {
|
|
178990
|
-
const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-
|
|
179496
|
+
const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
|
|
178991
179497
|
return await collectMultifocalDiagnostics({
|
|
178992
179498
|
api: this,
|
|
178993
179499
|
logger
|
|
@@ -230555,6 +231061,453 @@ var IntercomFailureReport = class {
|
|
|
230555
231061
|
/** The process-wide instance every camera in this addon notes into. */
|
|
230556
231062
|
var intercomFailureReport = new IntercomFailureReport();
|
|
230557
231063
|
//#endregion
|
|
231064
|
+
//#region src/snapshot-freshness.ts
|
|
231065
|
+
/**
|
|
231066
|
+
* The snapshot groups the freshness panel reports on, with the Baichuan
|
|
231067
|
+
* read behind each. Order is the display order.
|
|
231068
|
+
*/
|
|
231069
|
+
var SNAPSHOT_GROUPS = [
|
|
231070
|
+
{
|
|
231071
|
+
key: "imageSnapshot",
|
|
231072
|
+
label: "Image (getVideoInput)"
|
|
231073
|
+
},
|
|
231074
|
+
{
|
|
231075
|
+
key: "motionSnapshot",
|
|
231076
|
+
label: "Motion (getMotionAlarm)"
|
|
231077
|
+
},
|
|
231078
|
+
{
|
|
231079
|
+
key: "aiSensitivitySnapshot",
|
|
231080
|
+
label: "AI sensitivity (getAiDetectionFull)"
|
|
231081
|
+
},
|
|
231082
|
+
{
|
|
231083
|
+
key: "encSnapshot",
|
|
231084
|
+
label: "Encoder (getEnc)"
|
|
231085
|
+
},
|
|
231086
|
+
{
|
|
231087
|
+
key: "encOptionsSnapshot",
|
|
231088
|
+
label: "Encoder options (getEncOptions)"
|
|
231089
|
+
},
|
|
231090
|
+
{
|
|
231091
|
+
key: "maskSnapshot",
|
|
231092
|
+
label: "Privacy mask (getMask)"
|
|
231093
|
+
},
|
|
231094
|
+
{
|
|
231095
|
+
key: "audioNoiseSnapshot",
|
|
231096
|
+
label: "Audio noise (getAudioNoise)"
|
|
231097
|
+
},
|
|
231098
|
+
{
|
|
231099
|
+
key: "autoFocusSnapshot",
|
|
231100
|
+
label: "Auto-focus (getAutoFocus)"
|
|
231101
|
+
},
|
|
231102
|
+
{
|
|
231103
|
+
key: "netPortSnapshot",
|
|
231104
|
+
label: "Network ports (getNetPort)"
|
|
231105
|
+
},
|
|
231106
|
+
{
|
|
231107
|
+
key: "ntpSnapshot",
|
|
231108
|
+
label: "NTP (getNtp)"
|
|
231109
|
+
},
|
|
231110
|
+
{
|
|
231111
|
+
key: "systemGeneralSnapshot",
|
|
231112
|
+
label: "System general (getSystemGeneral)"
|
|
231113
|
+
},
|
|
231114
|
+
{
|
|
231115
|
+
key: "osdSnapshot",
|
|
231116
|
+
label: "OSD overlay (getOsd)"
|
|
231117
|
+
},
|
|
231118
|
+
{
|
|
231119
|
+
key: "ledSnapshot",
|
|
231120
|
+
label: "LEDs (getIrLights)"
|
|
231121
|
+
},
|
|
231122
|
+
{
|
|
231123
|
+
key: "pirSnapshot",
|
|
231124
|
+
label: "PIR (getPirInfo)"
|
|
231125
|
+
},
|
|
231126
|
+
{
|
|
231127
|
+
key: "autoRebootSnapshot",
|
|
231128
|
+
label: "Auto reboot (getAutoReboot)"
|
|
231129
|
+
},
|
|
231130
|
+
{
|
|
231131
|
+
key: "emailConfigSnapshot",
|
|
231132
|
+
label: "Email/SMTP (getEmail)"
|
|
231133
|
+
},
|
|
231134
|
+
{
|
|
231135
|
+
key: "capOptionsSnapshot",
|
|
231136
|
+
label: "Cap option probes (getOptions)"
|
|
231137
|
+
}
|
|
231138
|
+
];
|
|
231139
|
+
/**
|
|
231140
|
+
* Merge freshness stamps for the snapshot keys a persist actually wrote.
|
|
231141
|
+
* Returns a NEW map (immutability) — previous stamps for untouched groups
|
|
231142
|
+
* survive, written groups are stamped `now`. Call this from the same
|
|
231143
|
+
* `setAll` that writes the snapshots, with exactly the keys being written:
|
|
231144
|
+
* a failed probe writes no snapshot and therefore gets no stamp.
|
|
231145
|
+
*/
|
|
231146
|
+
function stampSnapshotFreshness(previous, writtenKeys, now) {
|
|
231147
|
+
const stamped = { ...previous };
|
|
231148
|
+
for (const key of writtenKeys) stamped[key] = now;
|
|
231149
|
+
return stamped;
|
|
231150
|
+
}
|
|
231151
|
+
/**
|
|
231152
|
+
* Resolve the age of one snapshot group from the cache. Sources, in order:
|
|
231153
|
+
* 1. `snapshotFetchedAt[key]` — the generic stamp map;
|
|
231154
|
+
* 2. a group-embedded stamp where one already existed before the map
|
|
231155
|
+
* (`osdSnapshot.fetchedAt`, `emailConfigSnapshot.lastReadAt`,
|
|
231156
|
+
* newest `capOptionsSnapshot[*].fetchedAt`);
|
|
231157
|
+
* 3. otherwise: the group is present but of unknown age.
|
|
231158
|
+
* An absent group is `never` — not-yet-read must never look like read.
|
|
231159
|
+
*/
|
|
231160
|
+
function resolveSnapshotAge(cache, key, now) {
|
|
231161
|
+
if ((cache === void 0 ? void 0 : Reflect.get(cache, key)) === void 0) return { state: "never" };
|
|
231162
|
+
const mapStamp = cache?.snapshotFetchedAt?.[key];
|
|
231163
|
+
const stamp = typeof mapStamp === "number" ? mapStamp : embeddedStamp(cache, key);
|
|
231164
|
+
if (typeof stamp !== "number") return { state: "unknown" };
|
|
231165
|
+
return {
|
|
231166
|
+
state: "known",
|
|
231167
|
+
fetchedAt: stamp,
|
|
231168
|
+
ageMs: Math.max(0, now - stamp)
|
|
231169
|
+
};
|
|
231170
|
+
}
|
|
231171
|
+
/** Pre-map stamps some groups already carried; kept as fallback so a legacy
|
|
231172
|
+
* cache written by today's OSD fix still reports a real age. */
|
|
231173
|
+
function embeddedStamp(cache, key) {
|
|
231174
|
+
if (key === "osdSnapshot") {
|
|
231175
|
+
const v = cache?.osdSnapshot?.fetchedAt;
|
|
231176
|
+
return typeof v === "number" ? v : void 0;
|
|
231177
|
+
}
|
|
231178
|
+
if (key === "emailConfigSnapshot") {
|
|
231179
|
+
const v = cache?.emailConfigSnapshot?.lastReadAt;
|
|
231180
|
+
return typeof v === "number" ? v : void 0;
|
|
231181
|
+
}
|
|
231182
|
+
if (key === "capOptionsSnapshot") {
|
|
231183
|
+
const stamps = Object.values(cache?.capOptionsSnapshot ?? {}).map((e) => e?.fetchedAt).filter((v) => typeof v === "number");
|
|
231184
|
+
return stamps.length > 0 ? Math.max(...stamps) : void 0;
|
|
231185
|
+
}
|
|
231186
|
+
}
|
|
231187
|
+
/** Human age: "12 s ago", "3 m ago", "5 h ago", "12 d ago". */
|
|
231188
|
+
function formatSnapshotAge(ageMs) {
|
|
231189
|
+
const s = Math.floor(ageMs / 1e3);
|
|
231190
|
+
if (s < 60) return `${s} s ago`;
|
|
231191
|
+
const m = Math.floor(s / 60);
|
|
231192
|
+
if (m < 60) return `${m} m ago`;
|
|
231193
|
+
const h = Math.floor(m / 60);
|
|
231194
|
+
if (h < 48) return `${h} h ago`;
|
|
231195
|
+
return `${Math.floor(h / 24)} d ago`;
|
|
231196
|
+
}
|
|
231197
|
+
/** One display line for a group. Unknown age is SAID, never smoothed over. */
|
|
231198
|
+
function formatSnapshotAgeLine(label, age) {
|
|
231199
|
+
switch (age.state) {
|
|
231200
|
+
case "never": return `${label}: never read`;
|
|
231201
|
+
case "unknown": return `${label}: age unknown (recorded before per-snapshot freshness tracking)`;
|
|
231202
|
+
case "known": return `${label}: read ${formatSnapshotAge(age.ageMs)} (${new Date(age.fetchedAt).toLocaleString()})`;
|
|
231203
|
+
}
|
|
231204
|
+
}
|
|
231205
|
+
/**
|
|
231206
|
+
* Read-only "Snapshot freshness" section (advanced tab, next to Debug).
|
|
231207
|
+
* Lists every snapshot group with its own age so "is CamStack's belief
|
|
231208
|
+
* current?" is answerable per fact, not per cache. Purely informational:
|
|
231209
|
+
* it triggers no reads — refresh stays on the existing operator-triggered
|
|
231210
|
+
* "Refresh from camera" action and the event-driven paths.
|
|
231211
|
+
*/
|
|
231212
|
+
function buildSnapshotFreshnessSection(cache, opts) {
|
|
231213
|
+
const lines = SNAPSHOT_GROUPS.map(({ key, label }) => formatSnapshotAgeLine(label, resolveSnapshotAge(cache, key, opts.now)));
|
|
231214
|
+
const probedAt = cache?.probedAt;
|
|
231215
|
+
const header = typeof probedAt === "number" ? `Feature probe: ${new Date(probedAt).toLocaleString()}` : "Feature probe: never recorded";
|
|
231216
|
+
return {
|
|
231217
|
+
id: "snapshotFreshness",
|
|
231218
|
+
tab: "advanced",
|
|
231219
|
+
title: "Snapshot freshness",
|
|
231220
|
+
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.",
|
|
231221
|
+
columns: 1,
|
|
231222
|
+
fields: [{
|
|
231223
|
+
type: "info",
|
|
231224
|
+
key: "snapshotFreshness",
|
|
231225
|
+
label: "Per-snapshot read times",
|
|
231226
|
+
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")}`
|
|
231227
|
+
}]
|
|
231228
|
+
};
|
|
231229
|
+
}
|
|
231230
|
+
var RawReadSliceSchema = _enum([
|
|
231231
|
+
"image",
|
|
231232
|
+
"motion",
|
|
231233
|
+
"ai",
|
|
231234
|
+
"enc",
|
|
231235
|
+
"encOptions",
|
|
231236
|
+
"mask",
|
|
231237
|
+
"audioNoise",
|
|
231238
|
+
"autofocus",
|
|
231239
|
+
"netPort",
|
|
231240
|
+
"ntp",
|
|
231241
|
+
"systemGeneral",
|
|
231242
|
+
"osd",
|
|
231243
|
+
"led",
|
|
231244
|
+
"pir",
|
|
231245
|
+
"autoReboot"
|
|
231246
|
+
]);
|
|
231247
|
+
/**
|
|
231248
|
+
* The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
|
|
231249
|
+
* in lockstep in both directions. Anything not in this map — email/SMTP
|
|
231250
|
+
* (`getEmail` echoes the camera's SMTP credentials), users, sessions, any
|
|
231251
|
+
* `set*` — cannot be requested.
|
|
231252
|
+
*/
|
|
231253
|
+
var RAW_READ_CATALOG = {
|
|
231254
|
+
image: {
|
|
231255
|
+
command: "getVideoInput",
|
|
231256
|
+
snapshotKey: "imageSnapshot",
|
|
231257
|
+
invoke: (api, channel) => api.getVideoInput(channel)
|
|
231258
|
+
},
|
|
231259
|
+
motion: {
|
|
231260
|
+
command: "getMotionAlarm",
|
|
231261
|
+
snapshotKey: "motionSnapshot",
|
|
231262
|
+
invoke: (api, channel) => api.getMotionAlarm(channel)
|
|
231263
|
+
},
|
|
231264
|
+
ai: {
|
|
231265
|
+
command: "getAiDetectTypes + getAiDetectionFull (per type)",
|
|
231266
|
+
snapshotKey: "aiSensitivitySnapshot",
|
|
231267
|
+
invoke: async (api, channel) => {
|
|
231268
|
+
const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
|
|
231269
|
+
const perType = {};
|
|
231270
|
+
for (const aiType of detectTypes ?? []) try {
|
|
231271
|
+
perType[aiType] = await api.getAiDetectionFull(channel, aiType);
|
|
231272
|
+
} catch (err) {
|
|
231273
|
+
perType[aiType] = { error: err instanceof Error ? err.message : String(err) };
|
|
231274
|
+
}
|
|
231275
|
+
return {
|
|
231276
|
+
detectTypes: detectTypes ?? [],
|
|
231277
|
+
perType
|
|
231278
|
+
};
|
|
231279
|
+
}
|
|
231280
|
+
},
|
|
231281
|
+
enc: {
|
|
231282
|
+
command: "getEnc",
|
|
231283
|
+
snapshotKey: "encSnapshot",
|
|
231284
|
+
invoke: (api, channel) => api.getEnc(channel)
|
|
231285
|
+
},
|
|
231286
|
+
encOptions: {
|
|
231287
|
+
command: "getEncOptions",
|
|
231288
|
+
snapshotKey: "encOptionsSnapshot",
|
|
231289
|
+
invoke: (api, channel) => api.getEncOptions(channel)
|
|
231290
|
+
},
|
|
231291
|
+
mask: {
|
|
231292
|
+
command: "getMask",
|
|
231293
|
+
snapshotKey: "maskSnapshot",
|
|
231294
|
+
invoke: (api, channel) => api.getMask(channel)
|
|
231295
|
+
},
|
|
231296
|
+
audioNoise: {
|
|
231297
|
+
command: "getAudioNoise",
|
|
231298
|
+
snapshotKey: "audioNoiseSnapshot",
|
|
231299
|
+
invoke: (api, channel) => api.getAudioNoise(channel)
|
|
231300
|
+
},
|
|
231301
|
+
autofocus: {
|
|
231302
|
+
command: "getAutoFocus",
|
|
231303
|
+
snapshotKey: "autoFocusSnapshot",
|
|
231304
|
+
invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
|
|
231305
|
+
},
|
|
231306
|
+
netPort: {
|
|
231307
|
+
command: "getNetPort",
|
|
231308
|
+
snapshotKey: "netPortSnapshot",
|
|
231309
|
+
invoke: (api) => api.getNetPort()
|
|
231310
|
+
},
|
|
231311
|
+
ntp: {
|
|
231312
|
+
command: "getNtp",
|
|
231313
|
+
snapshotKey: "ntpSnapshot",
|
|
231314
|
+
invoke: (api) => api.getNtp()
|
|
231315
|
+
},
|
|
231316
|
+
systemGeneral: {
|
|
231317
|
+
command: "getSystemGeneral",
|
|
231318
|
+
snapshotKey: "systemGeneralSnapshot",
|
|
231319
|
+
invoke: (api) => api.getSystemGeneral()
|
|
231320
|
+
},
|
|
231321
|
+
osd: {
|
|
231322
|
+
command: "getOsd",
|
|
231323
|
+
snapshotKey: "osdSnapshot",
|
|
231324
|
+
invoke: (api, channel) => api.getOsd(channel)
|
|
231325
|
+
},
|
|
231326
|
+
led: {
|
|
231327
|
+
command: "getIrLights",
|
|
231328
|
+
snapshotKey: "ledSnapshot",
|
|
231329
|
+
invoke: (api, channel) => api.getIrLights(channel)
|
|
231330
|
+
},
|
|
231331
|
+
pir: {
|
|
231332
|
+
command: "getPirInfo",
|
|
231333
|
+
snapshotKey: "pirSnapshot",
|
|
231334
|
+
invoke: (api, channel) => api.getPirInfo(channel)
|
|
231335
|
+
},
|
|
231336
|
+
autoReboot: {
|
|
231337
|
+
command: "getAutoReboot",
|
|
231338
|
+
snapshotKey: "autoRebootSnapshot",
|
|
231339
|
+
invoke: (api) => api.getAutoReboot()
|
|
231340
|
+
}
|
|
231341
|
+
};
|
|
231342
|
+
/** What CamStack currently believes about the slice, with its own age. */
|
|
231343
|
+
var BelievedStateSchema = object({
|
|
231344
|
+
/** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
|
|
231345
|
+
snapshot: unknown(),
|
|
231346
|
+
/** deviceCache field the projection lives in. */
|
|
231347
|
+
snapshotKey: string(),
|
|
231348
|
+
/** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
|
|
231349
|
+
age: union([
|
|
231350
|
+
object({ state: literal("never") }),
|
|
231351
|
+
object({ state: literal("unknown") }),
|
|
231352
|
+
object({
|
|
231353
|
+
state: literal("known"),
|
|
231354
|
+
fetchedAt: number().int(),
|
|
231355
|
+
ageMs: number().int().nonnegative()
|
|
231356
|
+
})
|
|
231357
|
+
])
|
|
231358
|
+
});
|
|
231359
|
+
var RawReadResultSchema = discriminatedUnion("ok", [object({
|
|
231360
|
+
ok: literal(true),
|
|
231361
|
+
deviceId: number().int(),
|
|
231362
|
+
slice: RawReadSliceSchema,
|
|
231363
|
+
command: string(),
|
|
231364
|
+
readAt: number().int(),
|
|
231365
|
+
/** The library's response, unprojected, as plain JSON. */
|
|
231366
|
+
camera: unknown(),
|
|
231367
|
+
believed: BelievedStateSchema
|
|
231368
|
+
}), object({
|
|
231369
|
+
ok: literal(false),
|
|
231370
|
+
deviceId: number().int(),
|
|
231371
|
+
slice: RawReadSliceSchema,
|
|
231372
|
+
reason: _enum([
|
|
231373
|
+
"sleeping",
|
|
231374
|
+
"login-failed",
|
|
231375
|
+
"read-failed"
|
|
231376
|
+
]),
|
|
231377
|
+
message: string(),
|
|
231378
|
+
/** The believed state is still reported — a refusal must not hide
|
|
231379
|
+
* what CamStack is currently serving. */
|
|
231380
|
+
believed: BelievedStateSchema
|
|
231381
|
+
})]);
|
|
231382
|
+
var RawReadInputSchema = object({
|
|
231383
|
+
deviceId: number().int().nonnegative(),
|
|
231384
|
+
slice: RawReadSliceSchema
|
|
231385
|
+
});
|
|
231386
|
+
function believedState(cache, entry, now) {
|
|
231387
|
+
const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
|
|
231388
|
+
return {
|
|
231389
|
+
snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
|
|
231390
|
+
snapshotKey: entry.snapshotKey,
|
|
231391
|
+
age
|
|
231392
|
+
};
|
|
231393
|
+
}
|
|
231394
|
+
/** Force a lib response to plain JSON: drops functions/undefined/prototypes,
|
|
231395
|
+
* guarantees the payload is serializable across the tRPC boundary. */
|
|
231396
|
+
function toPlainJson(value) {
|
|
231397
|
+
if (value === void 0) return null;
|
|
231398
|
+
return JSON.parse(JSON.stringify(value));
|
|
231399
|
+
}
|
|
231400
|
+
/**
|
|
231401
|
+
* Execute one raw read. Order matters:
|
|
231402
|
+
* 1. sleep gate (refuse loudly — logging in would BE the wake);
|
|
231403
|
+
* 2. login;
|
|
231404
|
+
* 3. the allow-listed read;
|
|
231405
|
+
* and every outcome — refusal included — carries the believed state so the
|
|
231406
|
+
* operator always sees both sides of the comparison.
|
|
231407
|
+
*/
|
|
231408
|
+
async function performRawRead(slice, deps) {
|
|
231409
|
+
const entry = RAW_READ_CATALOG[slice];
|
|
231410
|
+
const believed = believedState(deps.cache, entry, deps.now);
|
|
231411
|
+
if (deps.sleeping) {
|
|
231412
|
+
deps.logger.info("reolink raw read refused — battery cam is sleeping", {
|
|
231413
|
+
tags: { deviceId: deps.deviceId },
|
|
231414
|
+
meta: { slice }
|
|
231415
|
+
});
|
|
231416
|
+
return {
|
|
231417
|
+
ok: false,
|
|
231418
|
+
deviceId: deps.deviceId,
|
|
231419
|
+
slice,
|
|
231420
|
+
reason: "sleeping",
|
|
231421
|
+
message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
|
|
231422
|
+
believed
|
|
231423
|
+
};
|
|
231424
|
+
}
|
|
231425
|
+
let api;
|
|
231426
|
+
try {
|
|
231427
|
+
api = await deps.getApi();
|
|
231428
|
+
} catch (err) {
|
|
231429
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231430
|
+
deps.logger.info("reolink raw read login failed", {
|
|
231431
|
+
tags: { deviceId: deps.deviceId },
|
|
231432
|
+
meta: {
|
|
231433
|
+
slice,
|
|
231434
|
+
error: message
|
|
231435
|
+
}
|
|
231436
|
+
});
|
|
231437
|
+
return {
|
|
231438
|
+
ok: false,
|
|
231439
|
+
deviceId: deps.deviceId,
|
|
231440
|
+
slice,
|
|
231441
|
+
reason: "login-failed",
|
|
231442
|
+
message,
|
|
231443
|
+
believed
|
|
231444
|
+
};
|
|
231445
|
+
}
|
|
231446
|
+
try {
|
|
231447
|
+
const payload = await entry.invoke(api, deps.channel);
|
|
231448
|
+
deps.logger.info("reolink raw read served", {
|
|
231449
|
+
tags: { deviceId: deps.deviceId },
|
|
231450
|
+
meta: {
|
|
231451
|
+
slice,
|
|
231452
|
+
command: entry.command
|
|
231453
|
+
}
|
|
231454
|
+
});
|
|
231455
|
+
return {
|
|
231456
|
+
ok: true,
|
|
231457
|
+
deviceId: deps.deviceId,
|
|
231458
|
+
slice,
|
|
231459
|
+
command: entry.command,
|
|
231460
|
+
readAt: deps.now,
|
|
231461
|
+
camera: toPlainJson(payload),
|
|
231462
|
+
believed
|
|
231463
|
+
};
|
|
231464
|
+
} catch (err) {
|
|
231465
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231466
|
+
deps.logger.info("reolink raw read failed", {
|
|
231467
|
+
tags: { deviceId: deps.deviceId },
|
|
231468
|
+
meta: {
|
|
231469
|
+
slice,
|
|
231470
|
+
command: entry.command,
|
|
231471
|
+
error: message
|
|
231472
|
+
}
|
|
231473
|
+
});
|
|
231474
|
+
return {
|
|
231475
|
+
ok: false,
|
|
231476
|
+
deviceId: deps.deviceId,
|
|
231477
|
+
slice,
|
|
231478
|
+
reason: "read-failed",
|
|
231479
|
+
message,
|
|
231480
|
+
believed
|
|
231481
|
+
};
|
|
231482
|
+
}
|
|
231483
|
+
}
|
|
231484
|
+
//#endregion
|
|
231485
|
+
//#region src/debug-actions.ts
|
|
231486
|
+
/**
|
|
231487
|
+
* provider-reolink — customActions catalog (admin-only debug surface).
|
|
231488
|
+
*
|
|
231489
|
+
* Dispatched via `POST addons.custom
|
|
231490
|
+
* {addonId:'provider-reolink', action:'debugRawRead', input:{deviceId, slice}}`.
|
|
231491
|
+
*
|
|
231492
|
+
* Why an addon custom action and not a device action: `deviceManager.
|
|
231493
|
+
* runDeviceAction` (the `refresh-settings` / `refresh-sessions` shape) is
|
|
231494
|
+
* mounted `protected` and its dispatcher does not enforce the per-action
|
|
231495
|
+
* `auth` — any authenticated user could call it. `addons.custom` is the one
|
|
231496
|
+
* operator surface that enforces per-action `auth: 'admin'` server-side
|
|
231497
|
+
* (`ensureCustomActionAuth`) AND validates the addon's output against this
|
|
231498
|
+
* catalog. A debug surface that returns raw camera payloads is admin-only,
|
|
231499
|
+
* so it lives here (same wiring as `addon-benchmark` / `addon-notifiers`).
|
|
231500
|
+
*
|
|
231501
|
+
* `kind: 'query'` states the contract — the handler is read-only by
|
|
231502
|
+
* construction (see `raw-read.ts`: the slice enum maps onto an allow-list
|
|
231503
|
+
* of lib `get*` calls; no write is reachable). The `addons.custom` mount
|
|
231504
|
+
* itself is a single mutation procedure, so callers still POST.
|
|
231505
|
+
*/
|
|
231506
|
+
var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawReadInputSchema, RawReadResultSchema, {
|
|
231507
|
+
kind: "query",
|
|
231508
|
+
auth: "admin"
|
|
231509
|
+
}) });
|
|
231510
|
+
//#endregion
|
|
230558
231511
|
//#region src/log-channels.ts
|
|
230559
231512
|
/**
|
|
230560
231513
|
* The diagnostic log CHANNELS `provider-reolink` declares.
|
|
@@ -230767,6 +231720,130 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
|
|
|
230767
231720
|
});
|
|
230768
231721
|
}
|
|
230769
231722
|
//#endregion
|
|
231723
|
+
//#region src/osd-settings-section.ts
|
|
231724
|
+
/**
|
|
231725
|
+
* Camera snapshot → UNKNOWN (`null`). The last arm is the point: `?? true`
|
|
231726
|
+
* here is what rendered "the camera did not tell us" as an enabled overlay.
|
|
231727
|
+
* The snapshot is the ONLY input — the config keeps no copy to consult.
|
|
231728
|
+
*/
|
|
231729
|
+
function resolveOsdValues(snapshot) {
|
|
231730
|
+
return {
|
|
231731
|
+
osdChannelEnabled: snapshot?.channelEnabled ?? null,
|
|
231732
|
+
osdChannelName: snapshot?.channelName ?? "",
|
|
231733
|
+
osdTimeEnabled: snapshot?.timeEnabled ?? null,
|
|
231734
|
+
osdWatermark: snapshot?.watermark ?? null
|
|
231735
|
+
};
|
|
231736
|
+
}
|
|
231737
|
+
/**
|
|
231738
|
+
* Reader-side staleness (D224). A snapshot persisted before freshness
|
|
231739
|
+
* tracking has no `fetchedAt` and is treated as stale — that is exactly the
|
|
231740
|
+
* adoption-frozen reading this module exists to retire.
|
|
231741
|
+
*/
|
|
231742
|
+
function isOsdSnapshotStale(snapshot, now) {
|
|
231743
|
+
return now - (snapshot?.fetchedAt ?? 0) > OPERATOR_WRITTEN_STALE_MS;
|
|
231744
|
+
}
|
|
231745
|
+
var OSD_UNKNOWN_DESCRIPTION = "Not reported by the camera yet — the current state is unknown.";
|
|
231746
|
+
var NEVER_WOKEN_SUFFIX = "a sleeping battery camera is never woken to read settings.";
|
|
231747
|
+
/**
|
|
231748
|
+
* A boolean overlay toggle. Unknown (`null`) renders disabled with an honest
|
|
231749
|
+
* description — the switch component shows `Boolean(null)` = off, and the
|
|
231750
|
+
* disabled + "not reported" pairing keeps that from reading as a claim.
|
|
231751
|
+
*/
|
|
231752
|
+
function osdToggle(key, label, value, baseDescription) {
|
|
231753
|
+
const unknown = value === null;
|
|
231754
|
+
const description = unknown ? baseDescription ? `${baseDescription} ${OSD_UNKNOWN_DESCRIPTION}` : OSD_UNKNOWN_DESCRIPTION : baseDescription;
|
|
231755
|
+
return {
|
|
231756
|
+
type: "boolean",
|
|
231757
|
+
key,
|
|
231758
|
+
label,
|
|
231759
|
+
default: value,
|
|
231760
|
+
style: "switch",
|
|
231761
|
+
...description !== void 0 ? { description } : {},
|
|
231762
|
+
...unknown ? { disabled: true } : {}
|
|
231763
|
+
};
|
|
231764
|
+
}
|
|
231765
|
+
/**
|
|
231766
|
+
* State banner shown when the operator is NOT looking at a current reading:
|
|
231767
|
+
* - camera asleep and the mirror is stale → say what is shown and when it
|
|
231768
|
+
* was read, and that the camera is not woken for this;
|
|
231769
|
+
* - no reading has ever landed → say the toggles are unknown.
|
|
231770
|
+
* A fresh mirror on an awake camera renders no banner — serve-and-revalidate
|
|
231771
|
+
* keeps it honest silently.
|
|
231772
|
+
*/
|
|
231773
|
+
function buildOsdStateBanner(snapshot, opts) {
|
|
231774
|
+
const stale = isOsdSnapshotStale(snapshot, opts.now);
|
|
231775
|
+
if (opts.sleeping && stale) return {
|
|
231776
|
+
type: "info",
|
|
231777
|
+
key: "osdSnapshotState",
|
|
231778
|
+
label: "OSD state",
|
|
231779
|
+
variant: "warning",
|
|
231780
|
+
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}`
|
|
231781
|
+
};
|
|
231782
|
+
if (snapshot === void 0) return {
|
|
231783
|
+
type: "info",
|
|
231784
|
+
key: "osdSnapshotState",
|
|
231785
|
+
label: "OSD state",
|
|
231786
|
+
variant: "warning",
|
|
231787
|
+
content: "OSD state has not been read from this camera yet — unknown toggles are disabled until a read succeeds."
|
|
231788
|
+
};
|
|
231789
|
+
return null;
|
|
231790
|
+
}
|
|
231791
|
+
/**
|
|
231792
|
+
* A position value for display. Verbatim in quotes when the camera reported
|
|
231793
|
+
* one — an empty string IS a report and shows as `""` — and "not reported"
|
|
231794
|
+
* only when `getOsd` genuinely carried no string (tri-state, D337).
|
|
231795
|
+
*/
|
|
231796
|
+
function formatObservedPos(pos) {
|
|
231797
|
+
return typeof pos === "string" ? `"${pos}"` : "not reported";
|
|
231798
|
+
}
|
|
231799
|
+
/**
|
|
231800
|
+
* Read-only view of the overlay positions the camera reported. Deliberately
|
|
231801
|
+
* NOT a control: the `pos` vocabulary is unknown (loose string, no observed
|
|
231802
|
+
* values yet), so this field exists to make it observable per camera. A
|
|
231803
|
+
* position control can be designed once real values have been collected —
|
|
231804
|
+
* see the "osd overlay positions observed" info log in the probe.
|
|
231805
|
+
*/
|
|
231806
|
+
function buildOsdPositionsField(snapshot) {
|
|
231807
|
+
return {
|
|
231808
|
+
type: "info",
|
|
231809
|
+
key: "osdPositions",
|
|
231810
|
+
label: "Overlay positions",
|
|
231811
|
+
content: `Positions are kept exactly as configured on the camera and are read-only here.\nChannel name: ${formatObservedPos(snapshot?.channelPos)}\nTimestamp: ${formatObservedPos(snapshot?.timePos)}`
|
|
231812
|
+
};
|
|
231813
|
+
}
|
|
231814
|
+
/**
|
|
231815
|
+
* The "OSD overlay" section (writable via `setOsd`, cmd_id 25). One
|
|
231816
|
+
* read-modify-write `setOsd(OsdConfig)` push covers all four fields — the
|
|
231817
|
+
* dispatcher reads the current `OsdConfig` (`getOsd`) first so the stored
|
|
231818
|
+
* overlay positions (`pos`) survive untouched. Position itself is
|
|
231819
|
+
* camera-pixel/preset specific and only OBSERVED here, never written.
|
|
231820
|
+
*/
|
|
231821
|
+
function buildOsdSection(snapshot, values, opts) {
|
|
231822
|
+
const banner = buildOsdStateBanner(snapshot, opts);
|
|
231823
|
+
return {
|
|
231824
|
+
id: "osd",
|
|
231825
|
+
tab: "image",
|
|
231826
|
+
title: "OSD overlay",
|
|
231827
|
+
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.",
|
|
231828
|
+
columns: 2,
|
|
231829
|
+
fields: [
|
|
231830
|
+
...banner ? [banner] : [],
|
|
231831
|
+
osdToggle("osdChannelEnabled", "Channel name overlay", values.osdChannelEnabled, void 0),
|
|
231832
|
+
{
|
|
231833
|
+
type: "text",
|
|
231834
|
+
key: "osdChannelName",
|
|
231835
|
+
label: "Channel name",
|
|
231836
|
+
description: "Text shown in the channel-name overlay.",
|
|
231837
|
+
default: values.osdChannelName,
|
|
231838
|
+
placeholder: "Front door"
|
|
231839
|
+
},
|
|
231840
|
+
osdToggle("osdTimeEnabled", "Timestamp overlay", values.osdTimeEnabled, void 0),
|
|
231841
|
+
osdToggle("osdWatermark", "Watermark", values.osdWatermark, "The Reolink logo watermark overlay."),
|
|
231842
|
+
buildOsdPositionsField(snapshot)
|
|
231843
|
+
]
|
|
231844
|
+
};
|
|
231845
|
+
}
|
|
231846
|
+
//#endregion
|
|
230770
231847
|
//#region src/raw-state.ts
|
|
230771
231848
|
/**
|
|
230772
231849
|
* Source tag for every raw-state blob this provider emits.
|
|
@@ -231001,7 +232078,7 @@ var SirenAccessory = class extends BaseDevice {
|
|
|
231001
232078
|
this.ctx.logger.info("siren onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
|
|
231002
232079
|
try {
|
|
231003
232080
|
await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
|
|
231004
|
-
await sleep$
|
|
232081
|
+
await sleep$2(1e3);
|
|
231005
232082
|
} catch (err) {
|
|
231006
232083
|
this.ctx.logger.warn("siren wake before initial probe failed — proceeding anyway", {
|
|
231007
232084
|
tags: { deviceId: this.id },
|
|
@@ -231423,7 +232500,7 @@ var FloodlightAccessory = class extends BaseDevice {
|
|
|
231423
232500
|
this.ctx.logger.info("floodlight onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
|
|
231424
232501
|
try {
|
|
231425
232502
|
await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
|
|
231426
|
-
await sleep$
|
|
232503
|
+
await sleep$2(1e3);
|
|
231427
232504
|
} catch (err) {
|
|
231428
232505
|
this.ctx.logger.warn("floodlight wake before initial probe failed — proceeding anyway", {
|
|
231429
232506
|
tags: { deviceId: this.id },
|
|
@@ -231820,7 +232897,7 @@ var PirAccessory = class extends BaseDevice {
|
|
|
231820
232897
|
this.ctx.logger.info("pir onProbe forcing wake — initial probe with no cached state", { tags: { deviceId: this.id } });
|
|
231821
232898
|
try {
|
|
231822
232899
|
await (await this.api).wakeUp(this.channel, { waitAfterWakeMs: 2e3 });
|
|
231823
|
-
await sleep$
|
|
232900
|
+
await sleep$2(1e3);
|
|
231824
232901
|
} catch (err) {
|
|
231825
232902
|
this.ctx.logger.warn("pir wake before initial probe failed — proceeding anyway", {
|
|
231826
232903
|
tags: { deviceId: this.id },
|
|
@@ -233494,7 +234571,24 @@ var reolinkCameraSchema = object({
|
|
|
233494
234571
|
channelEnabled: boolean().nullable().optional(),
|
|
233495
234572
|
channelName: string().optional(),
|
|
233496
234573
|
timeEnabled: boolean().nullable().optional(),
|
|
233497
|
-
watermark: boolean().nullable().optional()
|
|
234574
|
+
watermark: boolean().nullable().optional(),
|
|
234575
|
+
/**
|
|
234576
|
+
* Overlay positions exactly as `getOsd` reported them — READ-ONLY
|
|
234577
|
+
* observations, never written (the `setOsd` read-modify-write
|
|
234578
|
+
* preserves the camera's stored `pos`). Tri-state: `null` means
|
|
234579
|
+
* the camera did not report a string; an empty string is a real
|
|
234580
|
+
* report. Captured so a vocabulary of live values can be
|
|
234581
|
+
* collected before any position control is designed.
|
|
234582
|
+
*/
|
|
234583
|
+
channelPos: string().nullable().optional(),
|
|
234584
|
+
timePos: string().nullable().optional(),
|
|
234585
|
+
/**
|
|
234586
|
+
* Wall-clock ms when this slice last landed from the camera.
|
|
234587
|
+
* Absent on snapshots persisted before freshness tracking —
|
|
234588
|
+
* `isOsdSnapshotStale` treats those as stale, which retires the
|
|
234589
|
+
* adoption-frozen readings this stamp was added for (D224).
|
|
234590
|
+
*/
|
|
234591
|
+
fetchedAt: number().int().optional()
|
|
233498
234592
|
}).optional(),
|
|
233499
234593
|
/**
|
|
233500
234594
|
* Snapshot of the camera's status + doorbell LED state from
|
|
@@ -233533,7 +234627,18 @@ var reolinkCameraSchema = object({
|
|
|
233533
234627
|
hour: number().int().nullable().optional(),
|
|
233534
234628
|
minute: number().int().nullable().optional(),
|
|
233535
234629
|
supported: boolean().optional()
|
|
233536
|
-
}).optional()
|
|
234630
|
+
}).optional(),
|
|
234631
|
+
/**
|
|
234632
|
+
* Per-snapshot freshness stamps (D224 generalised, D346): wall-clock
|
|
234633
|
+
* ms when each `*Snapshot` group in this cache was last WRITTEN from
|
|
234634
|
+
* a camera read, keyed by the group's field name (`encSnapshot`,
|
|
234635
|
+
* `osdSnapshot`, …). Written only by the persist sites that write
|
|
234636
|
+
* the group itself (`stampSnapshotFreshness`) — a failed probe
|
|
234637
|
+
* writes no snapshot and gets no stamp. A group with no entry here
|
|
234638
|
+
* (legacy persist) is of UNKNOWN age and must never read as fresh;
|
|
234639
|
+
* `resolveSnapshotAge` owns the tri-state.
|
|
234640
|
+
*/
|
|
234641
|
+
snapshotFetchedAt: record(string(), number().int()).optional()
|
|
233537
234642
|
}).loose().optional(),
|
|
233538
234643
|
/**
|
|
233539
234644
|
* Generic Baichuan debug logs. Forwarded as `DebugOptions.general`
|
|
@@ -233691,18 +234796,6 @@ var reolinkCameraSchema = object({
|
|
|
233691
234796
|
statusLedEnabled: boolean().optional(),
|
|
233692
234797
|
doorbellLedEnabled: boolean().optional(),
|
|
233693
234798
|
/**
|
|
233694
|
-
* On-screen display (OSD) overlay — pushed via `setOsd` (cmd_id 25,
|
|
233695
|
-
* read via 26). One read-modify-write `OsdConfig` push covers all four
|
|
233696
|
-
* fields so the camera keeps its stored overlay positions (`pos`)
|
|
233697
|
-
* untouched — only the enable flags, channel name text, and watermark
|
|
233698
|
-
* toggle change. Position is camera-pixel/preset specific (`pos` is a
|
|
233699
|
-
* loose string, not a clean enum), so it is intentionally NOT exposed.
|
|
233700
|
-
*/
|
|
233701
|
-
osdChannelEnabled: boolean().optional(),
|
|
233702
|
-
osdChannelName: string().max(64).optional(),
|
|
233703
|
-
osdTimeEnabled: boolean().optional(),
|
|
233704
|
-
osdWatermark: boolean().optional(),
|
|
233705
|
-
/**
|
|
233706
234799
|
* Audio output volume — pushed via `setAudioCfg` (cmd_id=265,
|
|
233707
234800
|
* read via 264). Reolink-spec range 0..100.
|
|
233708
234801
|
*/
|
|
@@ -234901,6 +235994,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
234901
235994
|
* legacy firmware that doesn't support some endpoints). */
|
|
234902
235995
|
lastSettingsSnapshotRetryAt = 0;
|
|
234903
235996
|
static SETTINGS_SNAPSHOT_RETRY_MIN_MS = 6e4;
|
|
235997
|
+
/** Debounce timestamp for the OSD serve-and-revalidate kick from
|
|
235998
|
+
* `getSettingsUISchema` (D224). Separate from
|
|
235999
|
+
* `lastSettingsSnapshotRetryAt` so an incomplete-cache retry and an
|
|
236000
|
+
* OSD staleness revalidate never suppress each other. */
|
|
236001
|
+
lastOsdRevalidateKickAt = 0;
|
|
234904
236002
|
/** True when any settings-snapshot field that drives a UI section
|
|
234905
236003
|
* is missing from the persisted cache. Drives the on-demand retry
|
|
234906
236004
|
* in `getSettingsUISchema`. */
|
|
@@ -234910,16 +236008,19 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
234910
236008
|
return cache.encSnapshot === void 0 || cache.encOptionsSnapshot === void 0 || cache.maskSnapshot === void 0 || cache.audioNoiseSnapshot === void 0 || cache.autoFocusSnapshot === void 0;
|
|
234911
236009
|
}
|
|
234912
236010
|
/**
|
|
234913
|
-
* Probe `getVideoInput
|
|
234914
|
-
* `deviceCache` snapshots.
|
|
234915
|
-
*
|
|
234916
|
-
*
|
|
234917
|
-
*
|
|
234918
|
-
*
|
|
236011
|
+
* Probe the parent-settings endpoints (`getVideoInput`, `getMotionAlarm`,
|
|
236012
|
+
* `getOsd`, …) and persist into the `deviceCache` snapshots. Runs on
|
|
236013
|
+
* activation, on battery wake transitions, after a settings save (scoped
|
|
236014
|
+
* to the changed slices), via the manual "Refresh from camera" action,
|
|
236015
|
+
* and from `getSettingsUISchema`'s serve-and-revalidate kicks — settings
|
|
236016
|
+
* opens serve the persisted snapshot immediately and revalidate stale
|
|
236017
|
+
* slices behind the form (D224). Image is readonly in the UI (lib lacks
|
|
236018
|
+
* `setVideoInput`); motion is writable via `setMotionAlarm` so its
|
|
236019
|
+
* snapshot also drives the dispatch's known-good baseline.
|
|
234919
236020
|
*/
|
|
234920
236021
|
async refreshParentSettingsSnapshot(slices) {
|
|
234921
236022
|
if (this.isBattery && this.sleeping) {
|
|
234922
|
-
this.ctx.logger.
|
|
236023
|
+
this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
|
|
234923
236024
|
return;
|
|
234924
236025
|
}
|
|
234925
236026
|
let api;
|
|
@@ -235072,14 +236173,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235072
236173
|
}
|
|
235073
236174
|
if (want("osd")) try {
|
|
235074
236175
|
const osd = await api.getOsd(channel);
|
|
236176
|
+
const channelPos = typeof osd.osdChannel?.pos === "string" ? osd.osdChannel.pos : null;
|
|
236177
|
+
const timePos = typeof osd.osdTime?.pos === "string" ? osd.osdTime.pos : null;
|
|
236178
|
+
const prevOsdSnapshot = this.config.get("deviceCache")?.osdSnapshot;
|
|
236179
|
+
if (prevOsdSnapshot?.channelPos !== channelPos || prevOsdSnapshot?.timePos !== timePos) this.ctx.logger.info("reolink osd overlay positions observed", {
|
|
236180
|
+
tags: { deviceId: this.id },
|
|
236181
|
+
meta: {
|
|
236182
|
+
channelPos,
|
|
236183
|
+
timePos
|
|
236184
|
+
}
|
|
236185
|
+
});
|
|
235075
236186
|
cacheUpdate.osdSnapshot = {
|
|
235076
236187
|
channelEnabled: typeof osd.osdChannel?.enable === "number" ? osd.osdChannel.enable === 1 : null,
|
|
235077
236188
|
channelName: typeof osd.osdChannel?.name === "string" ? osd.osdChannel.name : void 0,
|
|
235078
236189
|
timeEnabled: typeof osd.osdTime?.enable === "number" ? osd.osdTime.enable === 1 : null,
|
|
235079
|
-
watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null
|
|
236190
|
+
watermark: typeof osd.watermark === "number" ? osd.watermark === 1 : null,
|
|
236191
|
+
channelPos,
|
|
236192
|
+
timePos,
|
|
236193
|
+
fetchedAt: Date.now()
|
|
235080
236194
|
};
|
|
235081
236195
|
} catch (err) {
|
|
235082
|
-
this.ctx.logger.
|
|
236196
|
+
this.ctx.logger.info("reolink getOsd probe failed — OSD snapshot left stale", {
|
|
236197
|
+
tags: { deviceId: this.id },
|
|
236198
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
236199
|
+
});
|
|
235083
236200
|
}
|
|
235084
236201
|
if (want("led")) try {
|
|
235085
236202
|
const ledState = (await api.getIrLights(channel))?.body?.LedState;
|
|
@@ -235136,6 +236253,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235136
236253
|
}
|
|
235137
236254
|
if (Object.keys(cacheUpdate).length === 0) return;
|
|
235138
236255
|
const current = this.config.get("deviceCache") ?? {};
|
|
236256
|
+
const writtenSnapshotKeys = Object.keys(cacheUpdate).filter((k) => k.endsWith("Snapshot"));
|
|
236257
|
+
if (writtenSnapshotKeys.length > 0) cacheUpdate.snapshotFetchedAt = stampSnapshotFreshness(current.snapshotFetchedAt, writtenSnapshotKeys, Date.now());
|
|
235139
236258
|
try {
|
|
235140
236259
|
await this.config.setAll({ deviceCache: {
|
|
235141
236260
|
...current,
|
|
@@ -235149,6 +236268,28 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235149
236268
|
});
|
|
235150
236269
|
}
|
|
235151
236270
|
/**
|
|
236271
|
+
* Admin-only raw-read debug surface (D346): return the library's response
|
|
236272
|
+
* for one snapshot slice UNPROJECTED, next to the persisted snapshot and
|
|
236273
|
+
* its freshness, so "what does the camera report vs what does CamStack
|
|
236274
|
+
* believe" is answerable without Baichuan tracing. Read-only by
|
|
236275
|
+
* construction (the slice enum maps onto an allow-list of lib `get*`
|
|
236276
|
+
* calls — see `raw-read.ts`), writes nothing back to the cache, and the
|
|
236277
|
+
* sleep gate runs BEFORE any login: a sleeping battery cam refuses loudly
|
|
236278
|
+
* (logging in IS the wake) and still reports the believed state.
|
|
236279
|
+
* Dispatched by the provider's `debugRawRead` custom action.
|
|
236280
|
+
*/
|
|
236281
|
+
async debugRawRead(slice) {
|
|
236282
|
+
return performRawRead(slice, {
|
|
236283
|
+
deviceId: this.id,
|
|
236284
|
+
channel: this.getChannel(),
|
|
236285
|
+
sleeping: this.isBattery && this.sleeping,
|
|
236286
|
+
getApi: () => this.ensureApi(),
|
|
236287
|
+
cache: this.config.get("deviceCache"),
|
|
236288
|
+
logger: this.ctx.logger,
|
|
236289
|
+
now: Date.now()
|
|
236290
|
+
});
|
|
236291
|
+
}
|
|
236292
|
+
/**
|
|
235152
236293
|
* Declare on-camera accessory child devices the kernel should
|
|
235153
236294
|
* auto-spawn after `onCreated`. Each entry maps directly to a
|
|
235154
236295
|
* concrete accessory class via the existing `createAccessoryDevice`
|
|
@@ -235232,7 +236373,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235232
236373
|
waitAfterWakeMs: 2500,
|
|
235233
236374
|
attempts: 2
|
|
235234
236375
|
});
|
|
235235
|
-
await sleep$
|
|
236376
|
+
await sleep$2(1500);
|
|
235236
236377
|
})(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
|
|
235237
236378
|
return true;
|
|
235238
236379
|
} catch (err) {
|
|
@@ -235347,7 +236488,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235347
236488
|
sendNickname: email.sendNickname,
|
|
235348
236489
|
...task ? { taskEnabled: task.enable === 1 } : {},
|
|
235349
236490
|
lastReadAt: Date.now()
|
|
235350
|
-
}
|
|
236491
|
+
},
|
|
236492
|
+
snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["emailConfigSnapshot"], Date.now())
|
|
235351
236493
|
} });
|
|
235352
236494
|
this.ctx.logger.info("email-push: read camera email config", {
|
|
235353
236495
|
tags: { deviceId: this.id },
|
|
@@ -235529,7 +236671,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235529
236671
|
waitAfterWakeMs: 2500,
|
|
235530
236672
|
attempts: 2
|
|
235531
236673
|
});
|
|
235532
|
-
await sleep$
|
|
236674
|
+
await sleep$2(1500);
|
|
235533
236675
|
})(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeForStream timeout")), timeoutMs))]);
|
|
235534
236676
|
const CONFIRM_TIMEOUT_MS = 1e4;
|
|
235535
236677
|
const CONFIRM_POLL_MS = 1e3;
|
|
@@ -235559,7 +236701,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235559
236701
|
break;
|
|
235560
236702
|
}
|
|
235561
236703
|
}
|
|
235562
|
-
await sleep$
|
|
236704
|
+
await sleep$2(CONFIRM_POLL_MS);
|
|
235563
236705
|
}
|
|
235564
236706
|
const confirmSource = parent !== null ? "hub-summary" : "sleep-poll";
|
|
235565
236707
|
if (observedAwake && this.commitSleepState(false, confirmSource)) {
|
|
@@ -235791,7 +236933,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
235791
236933
|
async watchHubChildAwake(parent) {
|
|
235792
236934
|
const deadline = Date.now() + 45e3;
|
|
235793
236935
|
while (Date.now() < deadline) {
|
|
235794
|
-
await sleep$
|
|
236936
|
+
await sleep$2(5e3);
|
|
235795
236937
|
try {
|
|
235796
236938
|
if ((await (await parent.getApi()).getNvrChannelsSummary({ channels: [this.getChannel()] })).devices.find((d) => d.channel === this.getChannel())?.sleeping === false) {
|
|
235797
236939
|
if (this.commitSleepState(false, "hub-summary")) {
|
|
@@ -236754,7 +237896,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
236754
237896
|
value,
|
|
236755
237897
|
fetchedAt: Date.now()
|
|
236756
237898
|
}
|
|
236757
|
-
}
|
|
237899
|
+
},
|
|
237900
|
+
snapshotFetchedAt: stampSnapshotFreshness(current.snapshotFetchedAt, ["capOptionsSnapshot"], Date.now())
|
|
236758
237901
|
} });
|
|
236759
237902
|
} catch (err) {
|
|
236760
237903
|
this.ctx.logger.debug("cap options persist failed", {
|
|
@@ -237819,7 +238962,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
237819
238962
|
this.ctx.logger.info("intercom: cam sleeping — waking up before talk session", { tags: { deviceId: this.id } });
|
|
237820
238963
|
try {
|
|
237821
238964
|
await api.wakeUp(channel, { waitAfterWakeMs: 2e3 });
|
|
237822
|
-
await sleep$
|
|
238965
|
+
await sleep$2(1e3);
|
|
237823
238966
|
} catch (err) {
|
|
237824
238967
|
this.ctx.logger.warn("intercom: wakeUp failed — proceeding anyway", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
237825
238968
|
}
|
|
@@ -238119,13 +239262,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
238119
239262
|
await api.setAutoFocus(channel, enabled ? 0 : 1);
|
|
238120
239263
|
try {
|
|
238121
239264
|
const a = (await api.getAutoFocus(channel, { timeoutMs: 1500 }))?.body?.AutoFocus;
|
|
238122
|
-
if (a)
|
|
238123
|
-
|
|
238124
|
-
|
|
238125
|
-
|
|
238126
|
-
|
|
238127
|
-
|
|
238128
|
-
|
|
239265
|
+
if (a) {
|
|
239266
|
+
const afCurrent = this.config.get("deviceCache");
|
|
239267
|
+
await this.config.setAll({ deviceCache: {
|
|
239268
|
+
...afCurrent,
|
|
239269
|
+
autoFocusSnapshot: {
|
|
239270
|
+
enabled: typeof a.disable === "number" ? a.disable === 0 : null,
|
|
239271
|
+
supported: true
|
|
239272
|
+
},
|
|
239273
|
+
snapshotFetchedAt: stampSnapshotFreshness(afCurrent?.snapshotFetchedAt, ["autoFocusSnapshot"], Date.now())
|
|
239274
|
+
} });
|
|
239275
|
+
}
|
|
238129
239276
|
} catch {}
|
|
238130
239277
|
}
|
|
238131
239278
|
};
|
|
@@ -239490,13 +240637,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
239490
240637
|
const imgSnap = cache?.imageSnapshot ?? {};
|
|
239491
240638
|
const netSnap = cache?.netPortSnapshot ?? {};
|
|
239492
240639
|
const ntpSnap = cache?.ntpSnapshot ?? {};
|
|
240640
|
+
let kickedFullSnapshotRefresh = false;
|
|
239493
240641
|
if (this.hasIncompleteSettingsCache()) {
|
|
239494
240642
|
const now = Date.now();
|
|
239495
240643
|
if (now - this.lastSettingsSnapshotRetryAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
|
|
239496
240644
|
this.lastSettingsSnapshotRetryAt = now;
|
|
240645
|
+
kickedFullSnapshotRefresh = true;
|
|
239497
240646
|
this.refreshParentSettingsSnapshot().catch(() => {});
|
|
239498
240647
|
}
|
|
239499
240648
|
}
|
|
240649
|
+
const osdSnap = cache?.osdSnapshot;
|
|
240650
|
+
if (!kickedFullSnapshotRefresh && isOsdSnapshotStale(osdSnap, Date.now())) {
|
|
240651
|
+
const now = Date.now();
|
|
240652
|
+
if (now - this.lastOsdRevalidateKickAt > ReolinkCamera.SETTINGS_SNAPSHOT_RETRY_MIN_MS) {
|
|
240653
|
+
this.lastOsdRevalidateKickAt = now;
|
|
240654
|
+
this.refreshParentSettingsSnapshot(new Set(["osd"])).catch(() => {});
|
|
240655
|
+
}
|
|
240656
|
+
}
|
|
240657
|
+
const osdValues = resolveOsdValues(osdSnap);
|
|
239500
240658
|
const sessSnap = this.sessionsSnapshot;
|
|
239501
240659
|
const sessStale = sessSnap === null || Date.now() - sessSnap.ts > 6e4;
|
|
239502
240660
|
if (!this.isBattery && sessStale) this.refreshSessionsSnapshot().catch((err) => {
|
|
@@ -240043,45 +241201,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240043
241201
|
}] : []
|
|
240044
241202
|
]
|
|
240045
241203
|
},
|
|
240046
|
-
{
|
|
240047
|
-
|
|
240048
|
-
|
|
240049
|
-
|
|
240050
|
-
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.",
|
|
240051
|
-
columns: 2,
|
|
240052
|
-
fields: [
|
|
240053
|
-
{
|
|
240054
|
-
type: "boolean",
|
|
240055
|
-
key: "osdChannelEnabled",
|
|
240056
|
-
label: "Channel name overlay",
|
|
240057
|
-
default: cache?.osdSnapshot?.channelEnabled ?? true,
|
|
240058
|
-
style: "switch"
|
|
240059
|
-
},
|
|
240060
|
-
{
|
|
240061
|
-
type: "text",
|
|
240062
|
-
key: "osdChannelName",
|
|
240063
|
-
label: "Channel name",
|
|
240064
|
-
description: "Text shown in the channel-name overlay.",
|
|
240065
|
-
default: cache?.osdSnapshot?.channelName ?? "",
|
|
240066
|
-
placeholder: "Front door"
|
|
240067
|
-
},
|
|
240068
|
-
{
|
|
240069
|
-
type: "boolean",
|
|
240070
|
-
key: "osdTimeEnabled",
|
|
240071
|
-
label: "Timestamp overlay",
|
|
240072
|
-
default: cache?.osdSnapshot?.timeEnabled ?? true,
|
|
240073
|
-
style: "switch"
|
|
240074
|
-
},
|
|
240075
|
-
{
|
|
240076
|
-
type: "boolean",
|
|
240077
|
-
key: "osdWatermark",
|
|
240078
|
-
label: "Watermark",
|
|
240079
|
-
description: "The Reolink logo watermark overlay.",
|
|
240080
|
-
default: cache?.osdSnapshot?.watermark ?? false,
|
|
240081
|
-
style: "switch"
|
|
240082
|
-
}
|
|
240083
|
-
]
|
|
240084
|
-
},
|
|
241204
|
+
buildOsdSection(osdSnap, osdValues, {
|
|
241205
|
+
sleeping: this.isBattery && this.sleeping,
|
|
241206
|
+
now: Date.now()
|
|
241207
|
+
}),
|
|
240085
241208
|
{
|
|
240086
241209
|
id: "privacy-mask",
|
|
240087
241210
|
tab: "image",
|
|
@@ -240371,6 +241494,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240371
241494
|
]
|
|
240372
241495
|
}]
|
|
240373
241496
|
},
|
|
241497
|
+
buildSnapshotFreshnessSection(cache, {
|
|
241498
|
+
sleeping: this.isBattery && this.sleeping,
|
|
241499
|
+
now: Date.now()
|
|
241500
|
+
}),
|
|
240374
241501
|
...this.buildSessionsTabSections(),
|
|
240375
241502
|
...this.buildEmailPushSection(),
|
|
240376
241503
|
...this.buildEmailTabSections()
|
|
@@ -240437,10 +241564,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240437
241564
|
irLightsBrightness: this.config.get("irLightsBrightness") ?? 128,
|
|
240438
241565
|
statusLedEnabled: this.config.get("statusLedEnabled") ?? cache?.ledSnapshot?.statusEnabled ?? true,
|
|
240439
241566
|
doorbellLedEnabled: this.config.get("doorbellLedEnabled") ?? cache?.ledSnapshot?.doorbellEnabled ?? true,
|
|
240440
|
-
osdChannelEnabled:
|
|
240441
|
-
osdChannelName:
|
|
240442
|
-
osdTimeEnabled:
|
|
240443
|
-
osdWatermark:
|
|
241567
|
+
osdChannelEnabled: osdValues.osdChannelEnabled,
|
|
241568
|
+
osdChannelName: osdValues.osdChannelName,
|
|
241569
|
+
osdTimeEnabled: osdValues.osdTimeEnabled,
|
|
241570
|
+
osdWatermark: osdValues.osdWatermark,
|
|
240444
241571
|
audioVolume: this.config.get("audioVolume") ?? 50,
|
|
240445
241572
|
audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
|
|
240446
241573
|
audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
|
|
@@ -240459,7 +241586,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240459
241586
|
});
|
|
240460
241587
|
}
|
|
240461
241588
|
async applySettingsPatch(patch) {
|
|
240462
|
-
const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, ...rest } = patch;
|
|
241589
|
+
const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdTimeEnabled, osdWatermark, ...rest } = patch;
|
|
240463
241590
|
const emailFields = {
|
|
240464
241591
|
emailSmtpServer,
|
|
240465
241592
|
emailSmtpPort,
|
|
@@ -240478,8 +241605,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240478
241605
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
240479
241606
|
});
|
|
240480
241607
|
});
|
|
240481
|
-
|
|
240482
|
-
|
|
241608
|
+
const hasOsdPatch = [
|
|
241609
|
+
osdChannelEnabled,
|
|
241610
|
+
osdChannelName,
|
|
241611
|
+
osdTimeEnabled,
|
|
241612
|
+
osdWatermark
|
|
241613
|
+
].some((v) => v !== void 0);
|
|
241614
|
+
if (Object.keys(rest).length === 0 && !hasOsdPatch) return;
|
|
241615
|
+
if (Object.keys(rest).length > 0) await this.config.setAll(rest);
|
|
240483
241616
|
const typedPatch = patch;
|
|
240484
241617
|
if (typedPatch.host || typedPatch.port || typedPatch.username || typedPatch.password) {
|
|
240485
241618
|
await this.disconnectAll();
|
|
@@ -240672,12 +241805,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240672
241805
|
} catch (err) {
|
|
240673
241806
|
this.ctx.logger.warn("ir-lights push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
240674
241807
|
}
|
|
240675
|
-
if (
|
|
240676
|
-
"osdChannelEnabled",
|
|
240677
|
-
"osdChannelName",
|
|
240678
|
-
"osdTimeEnabled",
|
|
240679
|
-
"osdWatermark"
|
|
240680
|
-
].some((k) => k in patch)) try {
|
|
241808
|
+
if (hasOsdPatch) try {
|
|
240681
241809
|
const api = await this.ensureApi();
|
|
240682
241810
|
const channel = this.getChannel();
|
|
240683
241811
|
const current = await api.getOsd(channel);
|
|
@@ -240695,13 +241823,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240695
241823
|
watermark: current.watermark ?? 0
|
|
240696
241824
|
};
|
|
240697
241825
|
if (current.bgcolor !== void 0) next.bgcolor = current.bgcolor;
|
|
240698
|
-
if (typeof
|
|
240699
|
-
if (typeof
|
|
240700
|
-
if (typeof
|
|
240701
|
-
if (typeof
|
|
241826
|
+
if (typeof osdChannelEnabled === "boolean") next.osdChannel.enable = osdChannelEnabled ? 1 : 0;
|
|
241827
|
+
if (typeof osdChannelName === "string") next.osdChannel.name = osdChannelName;
|
|
241828
|
+
if (typeof osdTimeEnabled === "boolean") next.osdTime.enable = osdTimeEnabled ? 1 : 0;
|
|
241829
|
+
if (typeof osdWatermark === "boolean") next.watermark = osdWatermark ? 1 : 0;
|
|
240702
241830
|
await api.setOsd(channel, next);
|
|
240703
241831
|
} catch (err) {
|
|
240704
|
-
this.ctx.logger.warn("osd push failed
|
|
241832
|
+
this.ctx.logger.warn("osd push failed — camera keeps its current overlay state", {
|
|
241833
|
+
tags: { deviceId: this.id },
|
|
241834
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
241835
|
+
});
|
|
240705
241836
|
}
|
|
240706
241837
|
if ([
|
|
240707
241838
|
"audioVolume",
|
|
@@ -240842,7 +241973,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240842
241973
|
}
|
|
240843
241974
|
const changedSlices = slicesForPatch(patch);
|
|
240844
241975
|
if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
|
|
240845
|
-
this.ctx.logger.
|
|
241976
|
+
this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
|
|
241977
|
+
tags: { deviceId: this.id },
|
|
241978
|
+
meta: {
|
|
241979
|
+
slices: [...changedSlices],
|
|
241980
|
+
error: err instanceof Error ? err.message : String(err)
|
|
241981
|
+
}
|
|
241982
|
+
});
|
|
240846
241983
|
});
|
|
240847
241984
|
}
|
|
240848
241985
|
/**
|
|
@@ -243445,6 +244582,44 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
243445
244582
|
return regs;
|
|
243446
244583
|
}
|
|
243447
244584
|
/**
|
|
244585
|
+
* Compose the provider registrations from `onInitialize()` with this
|
|
244586
|
+
* addon's customActions catalog. `BaseDeviceProvider.onInitialize` is
|
|
244587
|
+
* typed `ProviderRegistration[]` (eleven sibling providers push onto it),
|
|
244588
|
+
* so the catalog joins at the `initialize()` seam instead — the runner
|
|
244589
|
+
* consumes the merged `AddonInitResult` exactly as it does for
|
|
244590
|
+
* addon-benchmark / addon-notifiers.
|
|
244591
|
+
*
|
|
244592
|
+
* Admin-only debug surface (D346): `debugRawRead` returns the lib's
|
|
244593
|
+
* response for one snapshot slice UNPROJECTED, next to the persisted
|
|
244594
|
+
* snapshot + its freshness. Registered as an addon customAction because
|
|
244595
|
+
* `addons.custom` is the one operator surface that enforces the
|
|
244596
|
+
* per-action `auth: 'admin'` server-side and validates output —
|
|
244597
|
+
* `deviceManager.runDeviceAction` does neither. The hub reads the static
|
|
244598
|
+
* catalog from this bundle's `customActions` export (see `index.ts`);
|
|
244599
|
+
* the child registers the handlers returned here.
|
|
244600
|
+
*/
|
|
244601
|
+
async initialize(context) {
|
|
244602
|
+
const base = await super.initialize(context);
|
|
244603
|
+
return {
|
|
244604
|
+
providers: base && base.providers ? base.providers : [],
|
|
244605
|
+
customActions: reolinkDebugActions,
|
|
244606
|
+
actionHandlers: { debugRawRead: (input) => this.debugRawRead(input) }
|
|
244607
|
+
};
|
|
244608
|
+
}
|
|
244609
|
+
/**
|
|
244610
|
+
* Route a `debugRawRead` custom action to the owning camera. Covers both
|
|
244611
|
+
* standalone cameras and NVR-adopted children — every live ReolinkCamera
|
|
244612
|
+
* in this runner is in the kernel device registry. Read-only end to end
|
|
244613
|
+
* (see `raw-read.ts`); a hub device or an unknown id refuses with a
|
|
244614
|
+
* message that names what it looked for.
|
|
244615
|
+
*/
|
|
244616
|
+
async debugRawRead(input) {
|
|
244617
|
+
const dev = this.ctx.kernel.deviceRegistry?.getById(input.deviceId);
|
|
244618
|
+
if (dev === void 0 || dev === null) throw new Error(`debugRawRead: device ${input.deviceId} not found in the reolink runner's registry`);
|
|
244619
|
+
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`);
|
|
244620
|
+
return dev.debugRawRead(input.slice);
|
|
244621
|
+
}
|
|
244622
|
+
/**
|
|
243448
244623
|
* Handle a broker-issued source-refresh request. With the lazy-publish
|
|
243449
244624
|
* model the broker always emits this on first dial of a
|
|
243450
244625
|
* `lazy:rfc4571:` placeholder URL — and re-emits it whenever the
|
|
@@ -243722,4 +244897,4 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
243722
244897
|
}
|
|
243723
244898
|
};
|
|
243724
244899
|
//#endregion
|
|
243725
|
-
export { ReolinkProviderAddon,
|
|
244900
|
+
export { ReolinkProviderAddon, collectMultifocalDiagnostics as a, createDiagnosticsBundle as c, sampleStreams as d, testChannelStreams as f, collectCgiDiagnostics as i, runAllDiagnosticsConsecutively as l, reolinkCameraSchema as n, collectNativeDiagnostics as o, reolinkDebugActions as r, collectNvrDiagnostics as s, ReolinkCamera as t, runMultifocalDiagnosticsConsecutively as u };
|