@camstack/addon-provider-reolink 1.2.46 → 1.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +218 -19
- package/dist/addon.mjs +218 -19
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -26,7 +26,7 @@ let fs_promises = require("fs/promises");
|
|
|
26
26
|
fs_promises = require_chunk.__toESM(fs_promises, 1);
|
|
27
27
|
let node_os = require("node:os");
|
|
28
28
|
node_os = require_chunk.__toESM(node_os);
|
|
29
|
-
//#region ../types/dist/event-category-
|
|
29
|
+
//#region ../types/dist/event-category-CIa_iT6b.mjs
|
|
30
30
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
31
31
|
EventCategory["SystemBoot"] = "system.boot";
|
|
32
32
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -42,6 +42,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
42
42
|
*/
|
|
43
43
|
EventCategory["SystemRestartCompleted"] = "system.restart-completed";
|
|
44
44
|
/**
|
|
45
|
+
* The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
|
|
46
|
+
* Emitted only when the material on disk actually changed, so an
|
|
47
|
+
* operator who trusted the old certificate by hand is told rather than
|
|
48
|
+
* discovering it as a browser error. Payload `TlsCertChangedPayload`.
|
|
49
|
+
*
|
|
50
|
+
* Rule: docs/decisions/adr-0227-*.md
|
|
51
|
+
*/
|
|
52
|
+
EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
|
|
53
|
+
/**
|
|
45
54
|
* A newer addon or server-root package version was found by the
|
|
46
55
|
* authoritative registry check. Emitted once when any observed
|
|
47
56
|
* `latestVersion` changes (or a package/node first appears behind);
|
|
@@ -19207,6 +19216,12 @@ var CameraStatusSchema = object({
|
|
|
19207
19216
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
19208
19217
|
fetchedAt: number()
|
|
19209
19218
|
});
|
|
19219
|
+
var InferenceDeviceExclusionReasonSchema = _enum([
|
|
19220
|
+
"disabled",
|
|
19221
|
+
"unavailable",
|
|
19222
|
+
"cannot-host-camera-root",
|
|
19223
|
+
"accelerator-preferred"
|
|
19224
|
+
]);
|
|
19210
19225
|
var NodeInferenceDeviceSchema = object({
|
|
19211
19226
|
/** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
|
|
19212
19227
|
key: string(),
|
|
@@ -19237,7 +19252,17 @@ var NodeInferenceDeviceSchema = object({
|
|
|
19237
19252
|
* available per format; this is the stored selection that becomes the
|
|
19238
19253
|
* default for EVERY camera landing on this accelerator.
|
|
19239
19254
|
*/
|
|
19240
|
-
steps: record(string(), DeviceStepConfigSchema).optional()
|
|
19255
|
+
steps: record(string(), DeviceStepConfigSchema).optional(),
|
|
19256
|
+
/**
|
|
19257
|
+
* `null` when the device IS a camera-root candidate on this node; otherwise
|
|
19258
|
+
* the reason the dispatcher drops it. Computed by the SAME
|
|
19259
|
+
* `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
|
|
19260
|
+
* never disagree with the election — deriving it in the UI from
|
|
19261
|
+
* `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
|
|
19262
|
+
* the node's model catalog) and `accelerator-preferred` (needs the node-wide
|
|
19263
|
+
* "an accelerator is serving" predicate).
|
|
19264
|
+
*/
|
|
19265
|
+
exclusion: InferenceDeviceExclusionReasonSchema.nullable()
|
|
19241
19266
|
});
|
|
19242
19267
|
var NodeInferenceDevicesSchema = object({
|
|
19243
19268
|
nodeId: string(),
|
|
@@ -24192,7 +24217,12 @@ var ListResultSchema = object({
|
|
|
24192
24217
|
probedAt: number()
|
|
24193
24218
|
});
|
|
24194
24219
|
var PreferredSchema = LocalInterfaceSchema.nullable();
|
|
24195
|
-
|
|
24220
|
+
/**
|
|
24221
|
+
* Candidate base URL for the SDK to race on connect. Order matters —
|
|
24222
|
+
* the SDK should attempt these top-to-bottom with a short per-candidate
|
|
24223
|
+
* timeout (e.g. 1500ms) and cache the winner for the session.
|
|
24224
|
+
*/
|
|
24225
|
+
var ConnectionEndpointSchema = object({
|
|
24196
24226
|
/** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
|
|
24197
24227
|
label: string(),
|
|
24198
24228
|
/** Fully-formed base URL with scheme + host + port. */
|
|
@@ -24235,7 +24265,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
|
|
|
24235
24265
|
* ordering between polls.
|
|
24236
24266
|
*/
|
|
24237
24267
|
priority: number()
|
|
24238
|
-
})
|
|
24268
|
+
});
|
|
24269
|
+
/**
|
|
24270
|
+
* Where the advertised local port came from. Ordered most → least
|
|
24271
|
+
* authoritative, and the whole point of returning it: a client must be able to
|
|
24272
|
+
* tell a FACT about the hub's socket from an echo of its own guess.
|
|
24273
|
+
*/
|
|
24274
|
+
var LocalPortSourceEnum = _enum([
|
|
24275
|
+
"server-config",
|
|
24276
|
+
"server-env",
|
|
24277
|
+
"caller-hint",
|
|
24278
|
+
"default"
|
|
24279
|
+
]);
|
|
24280
|
+
/** The port every LAN/loopback `baseUrl` in the same result was built with. */
|
|
24281
|
+
var AdvertisedLocalPortSchema = object({
|
|
24282
|
+
port: number().int().min(1).max(65535),
|
|
24283
|
+
source: LocalPortSourceEnum
|
|
24284
|
+
});
|
|
24285
|
+
var GetConnectionEndpointsResultSchema = object({
|
|
24286
|
+
endpoints: array(ConnectionEndpointSchema).readonly(),
|
|
24287
|
+
/**
|
|
24288
|
+
* The port the hub built the LAN/loopback URLs with, and where that number
|
|
24289
|
+
* came from.
|
|
24290
|
+
*
|
|
24291
|
+
* Returned rather than merely applied, because "the URL is right" and "the
|
|
24292
|
+
* client can KNOW the URL is right" are different properties. A client that
|
|
24293
|
+
* only sees a corrected URL cannot distinguish a hub that fixed the port from
|
|
24294
|
+
* a hub that echoed the port the client sent, so it cannot decide whether to
|
|
24295
|
+
* race the candidate or discard it. With `source` it can: anything but
|
|
24296
|
+
* `caller-hint` is the hub's own socket.
|
|
24297
|
+
*
|
|
24298
|
+
* Absent on hubs predating this field — a client that finds it missing is
|
|
24299
|
+
* talking to an echoing hub and must degrade exactly as it does for
|
|
24300
|
+
* `caller-hint`.
|
|
24301
|
+
*/
|
|
24302
|
+
localPort: AdvertisedLocalPortSchema
|
|
24303
|
+
});
|
|
24239
24304
|
/**
|
|
24240
24305
|
* The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
|
|
24241
24306
|
* AUTO (resolved from the candidate ranking at send time); `resolved` reports
|
|
@@ -24256,8 +24321,13 @@ var AllowedAddressesSchema = object({
|
|
|
24256
24321
|
*/
|
|
24257
24322
|
addresses: array(string()).readonly() });
|
|
24258
24323
|
method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
|
|
24259
|
-
/**
|
|
24260
|
-
|
|
24324
|
+
/**
|
|
24325
|
+
* LEGACY HINT — do not send from new code. Kept optional so clients
|
|
24326
|
+
* written against the echoing contract keep working; the hub uses it
|
|
24327
|
+
* only when it cannot read its own port, and says so via
|
|
24328
|
+
* `localPort.source === 'caller-hint'`.
|
|
24329
|
+
*/
|
|
24330
|
+
port: number().int().min(1).max(65535).optional(),
|
|
24261
24331
|
/** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
|
|
24262
24332
|
* candidate. Default `true`. */
|
|
24263
24333
|
includeLoopback: boolean().optional(),
|
|
@@ -30364,11 +30434,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
|
|
|
30364
30434
|
new Set(Object.values(DeviceType));
|
|
30365
30435
|
DeviceFeature.BatteryOperated;
|
|
30366
30436
|
var LAST_FETCHED_FIELD = "lastFetchedAt";
|
|
30437
|
+
/**
|
|
30438
|
+
* How long a bridge stops re-attempting a refresh that did not land.
|
|
30439
|
+
*
|
|
30440
|
+
* Sized against the failure it exists for: a camera whose control plane
|
|
30441
|
+
* is unreachable costs the FULL connect give-up (~3s on Linux when the
|
|
30442
|
+
* neighbour never answers, up to the client's own timeout otherwise) on
|
|
30443
|
+
* every attempt, and a failed refresh never advances `lastFetchedAt`, so
|
|
30444
|
+
* without a cooldown the slice is permanently stale and EVERY read pays
|
|
30445
|
+
* that price. One attempt per minute is enough to notice the camera
|
|
30446
|
+
* coming back; per-read is enough to stall the viewer's first paint.
|
|
30447
|
+
*/
|
|
30448
|
+
var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
|
|
30449
|
+
/**
|
|
30450
|
+
* The freshness window for a cap whose value changes ONLY when WE write it.
|
|
30451
|
+
*
|
|
30452
|
+
* The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
|
|
30453
|
+
* battery level, a day/night state that flips at dusk. It is the wrong number
|
|
30454
|
+
* for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
|
|
30455
|
+
* microphone on) change when an operator changes them, through a `setMask` /
|
|
30456
|
+
* `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
|
|
30457
|
+
* A write is therefore its own invalidation: the window never delays an
|
|
30458
|
+
* operator's own change, however long it is.
|
|
30459
|
+
*
|
|
30460
|
+
* What the window really bounds is the ONE case we do not write: somebody
|
|
30461
|
+
* changing the mask in the vendor app or the camera's web UI. Ten minutes is
|
|
30462
|
+
* the trade — that change is visible within ten minutes, and a fleet of 29
|
|
30463
|
+
* cameras behind a page that polls every 5s costs about 3 camera round trips a
|
|
30464
|
+
* minute instead of 696
|
|
30465
|
+
* ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
|
|
30466
|
+
*
|
|
30467
|
+
* Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
|
|
30468
|
+
* On the awaiting default a window this long does not remove the stall, it
|
|
30469
|
+
* merely makes it rarer and just as long.
|
|
30470
|
+
*/
|
|
30471
|
+
var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
|
|
30367
30472
|
function createRuntimeStateBridge(params) {
|
|
30368
30473
|
const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
|
|
30369
30474
|
const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
|
|
30475
|
+
const staleRead = params.staleRead ?? "await-refresh";
|
|
30370
30476
|
/** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
|
|
30371
30477
|
let missCooldownUntil = 0;
|
|
30478
|
+
/**
|
|
30479
|
+
* The refresh this bridge currently has in the air, if any.
|
|
30480
|
+
*
|
|
30481
|
+
* Providers single-flight their own camera client, so this is not what stops
|
|
30482
|
+
* a second round trip. What it stops is a POLLED reader JOINING one: under
|
|
30483
|
+
* `'serve-and-revalidate'` a read that finds a refresh already outstanding is
|
|
30484
|
+
* answered from the slice at once — even a cold, empty slice, which reports
|
|
30485
|
+
* UNKNOWN, which is the truth about a camera nobody has reached.
|
|
30486
|
+
*
|
|
30487
|
+
* That distinction is the whole cost of an unreachable camera. Measured on
|
|
30488
|
+
* the live hub (device 3629, an offline battery Reolink): its refresh takes
|
|
30489
|
+
* 23.1s to give up, so without this every 5s poll landing inside those 23s
|
|
30490
|
+
* joined the wait and spent the caller's full 1.2s source budget — five
|
|
30491
|
+
* stalled polls per cooldown cycle, for one camera, forever.
|
|
30492
|
+
*/
|
|
30493
|
+
let refreshInFlight = null;
|
|
30372
30494
|
const readFetchedAt = () => {
|
|
30373
30495
|
const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
|
|
30374
30496
|
return typeof value === "number" ? value : 0;
|
|
@@ -30389,14 +30511,14 @@ function createRuntimeStateBridge(params) {
|
|
|
30389
30511
|
}
|
|
30390
30512
|
});
|
|
30391
30513
|
};
|
|
30392
|
-
|
|
30393
|
-
|
|
30394
|
-
|
|
30395
|
-
|
|
30396
|
-
|
|
30397
|
-
|
|
30398
|
-
|
|
30399
|
-
|
|
30514
|
+
/**
|
|
30515
|
+
* One refresh attempt, plus the LANDED check that decides the cooldown.
|
|
30516
|
+
*
|
|
30517
|
+
* @param fetchedAt What `lastFetchedAt` was before the attempt — the only
|
|
30518
|
+
* evidence the bridge has that the refresh persisted
|
|
30519
|
+
* anything, since providers swallow their own camera errors.
|
|
30520
|
+
*/
|
|
30521
|
+
const runRefresh = async (fetchedAt) => {
|
|
30400
30522
|
try {
|
|
30401
30523
|
await refresh();
|
|
30402
30524
|
} catch (err) {
|
|
@@ -30412,6 +30534,41 @@ function createRuntimeStateBridge(params) {
|
|
|
30412
30534
|
}
|
|
30413
30535
|
openMissCooldown(void 0);
|
|
30414
30536
|
};
|
|
30537
|
+
/**
|
|
30538
|
+
* Start a refresh and remember it, at most one at a time.
|
|
30539
|
+
*
|
|
30540
|
+
* It never rejects: under `'serve-and-revalidate'` the caller is answered
|
|
30541
|
+
* from the slice either way, so a floating rejection would take the process
|
|
30542
|
+
* down for a fault the miss cooldown has already recorded and logged.
|
|
30543
|
+
*/
|
|
30544
|
+
const startRefresh = (fetchedAt) => {
|
|
30545
|
+
const existing = refreshInFlight;
|
|
30546
|
+
if (existing !== null) return existing;
|
|
30547
|
+
const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
|
|
30548
|
+
refreshInFlight = null;
|
|
30549
|
+
});
|
|
30550
|
+
refreshInFlight = started;
|
|
30551
|
+
return started;
|
|
30552
|
+
};
|
|
30553
|
+
const ensureFresh = async () => {
|
|
30554
|
+
const slice = runtimeState.getCapState(cap.name);
|
|
30555
|
+
const fetchedAt = readFetchedAt();
|
|
30556
|
+
if (slice && Date.now() - fetchedAt <= staleMs) {
|
|
30557
|
+
missCooldownUntil = 0;
|
|
30558
|
+
return;
|
|
30559
|
+
}
|
|
30560
|
+
if (Date.now() < missCooldownUntil) return;
|
|
30561
|
+
if (staleRead === "serve-and-revalidate") {
|
|
30562
|
+
if (refreshInFlight !== null) return;
|
|
30563
|
+
if (slice && fetchedAt > 0) {
|
|
30564
|
+
startRefresh(fetchedAt);
|
|
30565
|
+
return;
|
|
30566
|
+
}
|
|
30567
|
+
await startRefresh(fetchedAt);
|
|
30568
|
+
return;
|
|
30569
|
+
}
|
|
30570
|
+
await runRefresh(fetchedAt);
|
|
30571
|
+
};
|
|
30415
30572
|
const projectStatus = () => {
|
|
30416
30573
|
const slice = runtimeState.getCapState(cap.name);
|
|
30417
30574
|
if (!slice) return empty();
|
|
@@ -232395,6 +232552,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232395
232552
|
* within a day while removing the per-poll round-trip entirely. */
|
|
232396
232553
|
static CAP_OPTIONS_TTL_MS = 360 * 6e4;
|
|
232397
232554
|
/**
|
|
232555
|
+
* Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
|
|
232556
|
+
*
|
|
232557
|
+
* The same discipline `createRuntimeStateBridge` applies to a refresh that
|
|
232558
|
+
* did not land, for the same reason and against the same measurement. A probe
|
|
232559
|
+
* that fails persists nothing, so without this the next caller probes again —
|
|
232560
|
+
* and on a camera that is merely unreachable each attempt burns the full
|
|
232561
|
+
* connect give-up. Measured on the live hub (2026-08-21, device 3629, a
|
|
232562
|
+
* battery Reolink the registry already calls offline): `getOptions` took
|
|
232563
|
+
* **23.1s on every single call**, while its `getStatus` sibling answered in
|
|
232564
|
+
* 8ms because the bridge had already opened its cooldown. The status side was
|
|
232565
|
+
* bounded and the options side was not, and only one of them was visible.
|
|
232566
|
+
*/
|
|
232567
|
+
capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
|
|
232568
|
+
/**
|
|
232569
|
+
* Per-cap in-flight options probe, so concurrent callers share ONE.
|
|
232570
|
+
*
|
|
232571
|
+
* The cooldown above only opens once a probe has FAILED — and on an
|
|
232572
|
+
* unreachable camera that takes the full connect give-up (23.1s measured on
|
|
232573
|
+
* device 3629). Every poll arriving inside that window used to start its own
|
|
232574
|
+
* probe, so the cooldown never got a chance to bound anything: the fix for
|
|
232575
|
+
* "one dial per poll" has to cover the dial that is still in the air.
|
|
232576
|
+
*
|
|
232577
|
+
* A caller that finds one here is SERVED, not queued behind it — this method
|
|
232578
|
+
* sits on a polled path, and waiting for a probe that is not coming back is
|
|
232579
|
+
* how one unreachable camera stalls every poll it overlaps.
|
|
232580
|
+
*/
|
|
232581
|
+
capOptionsInFlight = /* @__PURE__ */ new Map();
|
|
232582
|
+
/**
|
|
232398
232583
|
* Read a persisted `getOptions` descriptor, validated with the cap's
|
|
232399
232584
|
* OWN Zod schema. Validation (not a cast) is what makes the
|
|
232400
232585
|
* `z.unknown()` blob type-safe: an entry written by an older addon
|
|
@@ -232452,7 +232637,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232452
232637
|
async resolveCapOptions(params) {
|
|
232453
232638
|
const { capName, schema, probe, fallback } = params;
|
|
232454
232639
|
const cached = this.readCapOptionsCache(capName, schema);
|
|
232455
|
-
if (
|
|
232640
|
+
if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
|
|
232641
|
+
if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
|
|
232642
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232643
|
+
return cached.value;
|
|
232644
|
+
}
|
|
232456
232645
|
if (this.isBattery && this.sleeping) {
|
|
232457
232646
|
this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
|
|
232458
232647
|
tags: { deviceId: this.id },
|
|
@@ -232463,19 +232652,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232463
232652
|
});
|
|
232464
232653
|
return cached?.value ?? fallback();
|
|
232465
232654
|
}
|
|
232655
|
+
const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
|
|
232656
|
+
if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
|
|
232657
|
+
const started = probe();
|
|
232658
|
+
this.capOptionsInFlight.set(capName, started);
|
|
232466
232659
|
try {
|
|
232467
|
-
const value = await
|
|
232660
|
+
const value = await started;
|
|
232468
232661
|
await this.persistCapOptions(capName, value);
|
|
232662
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232469
232663
|
return value;
|
|
232470
232664
|
} catch (err) {
|
|
232471
|
-
this.
|
|
232665
|
+
this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
|
|
232666
|
+
this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
|
|
232472
232667
|
tags: { deviceId: this.id },
|
|
232473
232668
|
meta: {
|
|
232474
232669
|
capName,
|
|
232670
|
+
cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
|
|
232671
|
+
hasCache: cached !== null,
|
|
232475
232672
|
error: err instanceof Error ? err.message : String(err)
|
|
232476
232673
|
}
|
|
232477
232674
|
});
|
|
232478
232675
|
return cached?.value ?? fallback();
|
|
232676
|
+
} finally {
|
|
232677
|
+
if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
|
|
232479
232678
|
}
|
|
232480
232679
|
}
|
|
232481
232680
|
/**
|
|
@@ -232916,7 +233115,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232916
233115
|
registerPrivacyMaskCap() {
|
|
232917
233116
|
const channel = this.getChannel();
|
|
232918
233117
|
const CAP_NAME = "privacy-mask";
|
|
232919
|
-
const STALE_MS = 1e4;
|
|
232920
233118
|
const refreshFromCamera = async () => {
|
|
232921
233119
|
if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
|
|
232922
233120
|
const promise = (async () => {
|
|
@@ -232969,7 +233167,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232969
233167
|
cap: privacyMaskCapability,
|
|
232970
233168
|
ownDeviceId: this.id,
|
|
232971
233169
|
refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
|
|
232972
|
-
staleMs:
|
|
233170
|
+
staleMs: OPERATOR_WRITTEN_STALE_MS,
|
|
233171
|
+
staleRead: "serve-and-revalidate",
|
|
232973
233172
|
empty: () => ({
|
|
232974
233173
|
enabled: false,
|
|
232975
233174
|
regions: [],
|
package/dist/addon.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import netImpl from "net";
|
|
|
21
21
|
import { fileURLToPath } from "url";
|
|
22
22
|
import { mkdir } from "fs/promises";
|
|
23
23
|
import os from "node:os";
|
|
24
|
-
//#region ../types/dist/event-category-
|
|
24
|
+
//#region ../types/dist/event-category-CIa_iT6b.mjs
|
|
25
25
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
26
26
|
EventCategory["SystemBoot"] = "system.boot";
|
|
27
27
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -37,6 +37,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
37
37
|
*/
|
|
38
38
|
EventCategory["SystemRestartCompleted"] = "system.restart-completed";
|
|
39
39
|
/**
|
|
40
|
+
* The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
|
|
41
|
+
* Emitted only when the material on disk actually changed, so an
|
|
42
|
+
* operator who trusted the old certificate by hand is told rather than
|
|
43
|
+
* discovering it as a browser error. Payload `TlsCertChangedPayload`.
|
|
44
|
+
*
|
|
45
|
+
* Rule: docs/decisions/adr-0227-*.md
|
|
46
|
+
*/
|
|
47
|
+
EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
|
|
48
|
+
/**
|
|
40
49
|
* A newer addon or server-root package version was found by the
|
|
41
50
|
* authoritative registry check. Emitted once when any observed
|
|
42
51
|
* `latestVersion` changes (or a package/node first appears behind);
|
|
@@ -19202,6 +19211,12 @@ var CameraStatusSchema = object({
|
|
|
19202
19211
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
19203
19212
|
fetchedAt: number()
|
|
19204
19213
|
});
|
|
19214
|
+
var InferenceDeviceExclusionReasonSchema = _enum([
|
|
19215
|
+
"disabled",
|
|
19216
|
+
"unavailable",
|
|
19217
|
+
"cannot-host-camera-root",
|
|
19218
|
+
"accelerator-preferred"
|
|
19219
|
+
]);
|
|
19205
19220
|
var NodeInferenceDeviceSchema = object({
|
|
19206
19221
|
/** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
|
|
19207
19222
|
key: string(),
|
|
@@ -19232,7 +19247,17 @@ var NodeInferenceDeviceSchema = object({
|
|
|
19232
19247
|
* available per format; this is the stored selection that becomes the
|
|
19233
19248
|
* default for EVERY camera landing on this accelerator.
|
|
19234
19249
|
*/
|
|
19235
|
-
steps: record(string(), DeviceStepConfigSchema).optional()
|
|
19250
|
+
steps: record(string(), DeviceStepConfigSchema).optional(),
|
|
19251
|
+
/**
|
|
19252
|
+
* `null` when the device IS a camera-root candidate on this node; otherwise
|
|
19253
|
+
* the reason the dispatcher drops it. Computed by the SAME
|
|
19254
|
+
* `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
|
|
19255
|
+
* never disagree with the election — deriving it in the UI from
|
|
19256
|
+
* `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
|
|
19257
|
+
* the node's model catalog) and `accelerator-preferred` (needs the node-wide
|
|
19258
|
+
* "an accelerator is serving" predicate).
|
|
19259
|
+
*/
|
|
19260
|
+
exclusion: InferenceDeviceExclusionReasonSchema.nullable()
|
|
19236
19261
|
});
|
|
19237
19262
|
var NodeInferenceDevicesSchema = object({
|
|
19238
19263
|
nodeId: string(),
|
|
@@ -24187,7 +24212,12 @@ var ListResultSchema = object({
|
|
|
24187
24212
|
probedAt: number()
|
|
24188
24213
|
});
|
|
24189
24214
|
var PreferredSchema = LocalInterfaceSchema.nullable();
|
|
24190
|
-
|
|
24215
|
+
/**
|
|
24216
|
+
* Candidate base URL for the SDK to race on connect. Order matters —
|
|
24217
|
+
* the SDK should attempt these top-to-bottom with a short per-candidate
|
|
24218
|
+
* timeout (e.g. 1500ms) and cache the winner for the session.
|
|
24219
|
+
*/
|
|
24220
|
+
var ConnectionEndpointSchema = object({
|
|
24191
24221
|
/** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
|
|
24192
24222
|
label: string(),
|
|
24193
24223
|
/** Fully-formed base URL with scheme + host + port. */
|
|
@@ -24230,7 +24260,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
|
|
|
24230
24260
|
* ordering between polls.
|
|
24231
24261
|
*/
|
|
24232
24262
|
priority: number()
|
|
24233
|
-
})
|
|
24263
|
+
});
|
|
24264
|
+
/**
|
|
24265
|
+
* Where the advertised local port came from. Ordered most → least
|
|
24266
|
+
* authoritative, and the whole point of returning it: a client must be able to
|
|
24267
|
+
* tell a FACT about the hub's socket from an echo of its own guess.
|
|
24268
|
+
*/
|
|
24269
|
+
var LocalPortSourceEnum = _enum([
|
|
24270
|
+
"server-config",
|
|
24271
|
+
"server-env",
|
|
24272
|
+
"caller-hint",
|
|
24273
|
+
"default"
|
|
24274
|
+
]);
|
|
24275
|
+
/** The port every LAN/loopback `baseUrl` in the same result was built with. */
|
|
24276
|
+
var AdvertisedLocalPortSchema = object({
|
|
24277
|
+
port: number().int().min(1).max(65535),
|
|
24278
|
+
source: LocalPortSourceEnum
|
|
24279
|
+
});
|
|
24280
|
+
var GetConnectionEndpointsResultSchema = object({
|
|
24281
|
+
endpoints: array(ConnectionEndpointSchema).readonly(),
|
|
24282
|
+
/**
|
|
24283
|
+
* The port the hub built the LAN/loopback URLs with, and where that number
|
|
24284
|
+
* came from.
|
|
24285
|
+
*
|
|
24286
|
+
* Returned rather than merely applied, because "the URL is right" and "the
|
|
24287
|
+
* client can KNOW the URL is right" are different properties. A client that
|
|
24288
|
+
* only sees a corrected URL cannot distinguish a hub that fixed the port from
|
|
24289
|
+
* a hub that echoed the port the client sent, so it cannot decide whether to
|
|
24290
|
+
* race the candidate or discard it. With `source` it can: anything but
|
|
24291
|
+
* `caller-hint` is the hub's own socket.
|
|
24292
|
+
*
|
|
24293
|
+
* Absent on hubs predating this field — a client that finds it missing is
|
|
24294
|
+
* talking to an echoing hub and must degrade exactly as it does for
|
|
24295
|
+
* `caller-hint`.
|
|
24296
|
+
*/
|
|
24297
|
+
localPort: AdvertisedLocalPortSchema
|
|
24298
|
+
});
|
|
24234
24299
|
/**
|
|
24235
24300
|
* The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
|
|
24236
24301
|
* AUTO (resolved from the candidate ranking at send time); `resolved` reports
|
|
@@ -24251,8 +24316,13 @@ var AllowedAddressesSchema = object({
|
|
|
24251
24316
|
*/
|
|
24252
24317
|
addresses: array(string()).readonly() });
|
|
24253
24318
|
method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
|
|
24254
|
-
/**
|
|
24255
|
-
|
|
24319
|
+
/**
|
|
24320
|
+
* LEGACY HINT — do not send from new code. Kept optional so clients
|
|
24321
|
+
* written against the echoing contract keep working; the hub uses it
|
|
24322
|
+
* only when it cannot read its own port, and says so via
|
|
24323
|
+
* `localPort.source === 'caller-hint'`.
|
|
24324
|
+
*/
|
|
24325
|
+
port: number().int().min(1).max(65535).optional(),
|
|
24256
24326
|
/** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
|
|
24257
24327
|
* candidate. Default `true`. */
|
|
24258
24328
|
includeLoopback: boolean().optional(),
|
|
@@ -30359,11 +30429,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
|
|
|
30359
30429
|
new Set(Object.values(DeviceType));
|
|
30360
30430
|
DeviceFeature.BatteryOperated;
|
|
30361
30431
|
var LAST_FETCHED_FIELD = "lastFetchedAt";
|
|
30432
|
+
/**
|
|
30433
|
+
* How long a bridge stops re-attempting a refresh that did not land.
|
|
30434
|
+
*
|
|
30435
|
+
* Sized against the failure it exists for: a camera whose control plane
|
|
30436
|
+
* is unreachable costs the FULL connect give-up (~3s on Linux when the
|
|
30437
|
+
* neighbour never answers, up to the client's own timeout otherwise) on
|
|
30438
|
+
* every attempt, and a failed refresh never advances `lastFetchedAt`, so
|
|
30439
|
+
* without a cooldown the slice is permanently stale and EVERY read pays
|
|
30440
|
+
* that price. One attempt per minute is enough to notice the camera
|
|
30441
|
+
* coming back; per-read is enough to stall the viewer's first paint.
|
|
30442
|
+
*/
|
|
30443
|
+
var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
|
|
30444
|
+
/**
|
|
30445
|
+
* The freshness window for a cap whose value changes ONLY when WE write it.
|
|
30446
|
+
*
|
|
30447
|
+
* The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
|
|
30448
|
+
* battery level, a day/night state that flips at dusk. It is the wrong number
|
|
30449
|
+
* for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
|
|
30450
|
+
* microphone on) change when an operator changes them, through a `setMask` /
|
|
30451
|
+
* `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
|
|
30452
|
+
* A write is therefore its own invalidation: the window never delays an
|
|
30453
|
+
* operator's own change, however long it is.
|
|
30454
|
+
*
|
|
30455
|
+
* What the window really bounds is the ONE case we do not write: somebody
|
|
30456
|
+
* changing the mask in the vendor app or the camera's web UI. Ten minutes is
|
|
30457
|
+
* the trade — that change is visible within ten minutes, and a fleet of 29
|
|
30458
|
+
* cameras behind a page that polls every 5s costs about 3 camera round trips a
|
|
30459
|
+
* minute instead of 696
|
|
30460
|
+
* ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
|
|
30461
|
+
*
|
|
30462
|
+
* Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
|
|
30463
|
+
* On the awaiting default a window this long does not remove the stall, it
|
|
30464
|
+
* merely makes it rarer and just as long.
|
|
30465
|
+
*/
|
|
30466
|
+
var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
|
|
30362
30467
|
function createRuntimeStateBridge(params) {
|
|
30363
30468
|
const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
|
|
30364
30469
|
const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
|
|
30470
|
+
const staleRead = params.staleRead ?? "await-refresh";
|
|
30365
30471
|
/** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
|
|
30366
30472
|
let missCooldownUntil = 0;
|
|
30473
|
+
/**
|
|
30474
|
+
* The refresh this bridge currently has in the air, if any.
|
|
30475
|
+
*
|
|
30476
|
+
* Providers single-flight their own camera client, so this is not what stops
|
|
30477
|
+
* a second round trip. What it stops is a POLLED reader JOINING one: under
|
|
30478
|
+
* `'serve-and-revalidate'` a read that finds a refresh already outstanding is
|
|
30479
|
+
* answered from the slice at once — even a cold, empty slice, which reports
|
|
30480
|
+
* UNKNOWN, which is the truth about a camera nobody has reached.
|
|
30481
|
+
*
|
|
30482
|
+
* That distinction is the whole cost of an unreachable camera. Measured on
|
|
30483
|
+
* the live hub (device 3629, an offline battery Reolink): its refresh takes
|
|
30484
|
+
* 23.1s to give up, so without this every 5s poll landing inside those 23s
|
|
30485
|
+
* joined the wait and spent the caller's full 1.2s source budget — five
|
|
30486
|
+
* stalled polls per cooldown cycle, for one camera, forever.
|
|
30487
|
+
*/
|
|
30488
|
+
let refreshInFlight = null;
|
|
30367
30489
|
const readFetchedAt = () => {
|
|
30368
30490
|
const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
|
|
30369
30491
|
return typeof value === "number" ? value : 0;
|
|
@@ -30384,14 +30506,14 @@ function createRuntimeStateBridge(params) {
|
|
|
30384
30506
|
}
|
|
30385
30507
|
});
|
|
30386
30508
|
};
|
|
30387
|
-
|
|
30388
|
-
|
|
30389
|
-
|
|
30390
|
-
|
|
30391
|
-
|
|
30392
|
-
|
|
30393
|
-
|
|
30394
|
-
|
|
30509
|
+
/**
|
|
30510
|
+
* One refresh attempt, plus the LANDED check that decides the cooldown.
|
|
30511
|
+
*
|
|
30512
|
+
* @param fetchedAt What `lastFetchedAt` was before the attempt — the only
|
|
30513
|
+
* evidence the bridge has that the refresh persisted
|
|
30514
|
+
* anything, since providers swallow their own camera errors.
|
|
30515
|
+
*/
|
|
30516
|
+
const runRefresh = async (fetchedAt) => {
|
|
30395
30517
|
try {
|
|
30396
30518
|
await refresh();
|
|
30397
30519
|
} catch (err) {
|
|
@@ -30407,6 +30529,41 @@ function createRuntimeStateBridge(params) {
|
|
|
30407
30529
|
}
|
|
30408
30530
|
openMissCooldown(void 0);
|
|
30409
30531
|
};
|
|
30532
|
+
/**
|
|
30533
|
+
* Start a refresh and remember it, at most one at a time.
|
|
30534
|
+
*
|
|
30535
|
+
* It never rejects: under `'serve-and-revalidate'` the caller is answered
|
|
30536
|
+
* from the slice either way, so a floating rejection would take the process
|
|
30537
|
+
* down for a fault the miss cooldown has already recorded and logged.
|
|
30538
|
+
*/
|
|
30539
|
+
const startRefresh = (fetchedAt) => {
|
|
30540
|
+
const existing = refreshInFlight;
|
|
30541
|
+
if (existing !== null) return existing;
|
|
30542
|
+
const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
|
|
30543
|
+
refreshInFlight = null;
|
|
30544
|
+
});
|
|
30545
|
+
refreshInFlight = started;
|
|
30546
|
+
return started;
|
|
30547
|
+
};
|
|
30548
|
+
const ensureFresh = async () => {
|
|
30549
|
+
const slice = runtimeState.getCapState(cap.name);
|
|
30550
|
+
const fetchedAt = readFetchedAt();
|
|
30551
|
+
if (slice && Date.now() - fetchedAt <= staleMs) {
|
|
30552
|
+
missCooldownUntil = 0;
|
|
30553
|
+
return;
|
|
30554
|
+
}
|
|
30555
|
+
if (Date.now() < missCooldownUntil) return;
|
|
30556
|
+
if (staleRead === "serve-and-revalidate") {
|
|
30557
|
+
if (refreshInFlight !== null) return;
|
|
30558
|
+
if (slice && fetchedAt > 0) {
|
|
30559
|
+
startRefresh(fetchedAt);
|
|
30560
|
+
return;
|
|
30561
|
+
}
|
|
30562
|
+
await startRefresh(fetchedAt);
|
|
30563
|
+
return;
|
|
30564
|
+
}
|
|
30565
|
+
await runRefresh(fetchedAt);
|
|
30566
|
+
};
|
|
30410
30567
|
const projectStatus = () => {
|
|
30411
30568
|
const slice = runtimeState.getCapState(cap.name);
|
|
30412
30569
|
if (!slice) return empty();
|
|
@@ -232375,6 +232532,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232375
232532
|
* within a day while removing the per-poll round-trip entirely. */
|
|
232376
232533
|
static CAP_OPTIONS_TTL_MS = 360 * 6e4;
|
|
232377
232534
|
/**
|
|
232535
|
+
* Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
|
|
232536
|
+
*
|
|
232537
|
+
* The same discipline `createRuntimeStateBridge` applies to a refresh that
|
|
232538
|
+
* did not land, for the same reason and against the same measurement. A probe
|
|
232539
|
+
* that fails persists nothing, so without this the next caller probes again —
|
|
232540
|
+
* and on a camera that is merely unreachable each attempt burns the full
|
|
232541
|
+
* connect give-up. Measured on the live hub (2026-08-21, device 3629, a
|
|
232542
|
+
* battery Reolink the registry already calls offline): `getOptions` took
|
|
232543
|
+
* **23.1s on every single call**, while its `getStatus` sibling answered in
|
|
232544
|
+
* 8ms because the bridge had already opened its cooldown. The status side was
|
|
232545
|
+
* bounded and the options side was not, and only one of them was visible.
|
|
232546
|
+
*/
|
|
232547
|
+
capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
|
|
232548
|
+
/**
|
|
232549
|
+
* Per-cap in-flight options probe, so concurrent callers share ONE.
|
|
232550
|
+
*
|
|
232551
|
+
* The cooldown above only opens once a probe has FAILED — and on an
|
|
232552
|
+
* unreachable camera that takes the full connect give-up (23.1s measured on
|
|
232553
|
+
* device 3629). Every poll arriving inside that window used to start its own
|
|
232554
|
+
* probe, so the cooldown never got a chance to bound anything: the fix for
|
|
232555
|
+
* "one dial per poll" has to cover the dial that is still in the air.
|
|
232556
|
+
*
|
|
232557
|
+
* A caller that finds one here is SERVED, not queued behind it — this method
|
|
232558
|
+
* sits on a polled path, and waiting for a probe that is not coming back is
|
|
232559
|
+
* how one unreachable camera stalls every poll it overlaps.
|
|
232560
|
+
*/
|
|
232561
|
+
capOptionsInFlight = /* @__PURE__ */ new Map();
|
|
232562
|
+
/**
|
|
232378
232563
|
* Read a persisted `getOptions` descriptor, validated with the cap's
|
|
232379
232564
|
* OWN Zod schema. Validation (not a cast) is what makes the
|
|
232380
232565
|
* `z.unknown()` blob type-safe: an entry written by an older addon
|
|
@@ -232432,7 +232617,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232432
232617
|
async resolveCapOptions(params) {
|
|
232433
232618
|
const { capName, schema, probe, fallback } = params;
|
|
232434
232619
|
const cached = this.readCapOptionsCache(capName, schema);
|
|
232435
|
-
if (
|
|
232620
|
+
if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
|
|
232621
|
+
if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
|
|
232622
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232623
|
+
return cached.value;
|
|
232624
|
+
}
|
|
232436
232625
|
if (this.isBattery && this.sleeping) {
|
|
232437
232626
|
this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
|
|
232438
232627
|
tags: { deviceId: this.id },
|
|
@@ -232443,19 +232632,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232443
232632
|
});
|
|
232444
232633
|
return cached?.value ?? fallback();
|
|
232445
232634
|
}
|
|
232635
|
+
const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
|
|
232636
|
+
if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
|
|
232637
|
+
const started = probe();
|
|
232638
|
+
this.capOptionsInFlight.set(capName, started);
|
|
232446
232639
|
try {
|
|
232447
|
-
const value = await
|
|
232640
|
+
const value = await started;
|
|
232448
232641
|
await this.persistCapOptions(capName, value);
|
|
232642
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232449
232643
|
return value;
|
|
232450
232644
|
} catch (err) {
|
|
232451
|
-
this.
|
|
232645
|
+
this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
|
|
232646
|
+
this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
|
|
232452
232647
|
tags: { deviceId: this.id },
|
|
232453
232648
|
meta: {
|
|
232454
232649
|
capName,
|
|
232650
|
+
cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
|
|
232651
|
+
hasCache: cached !== null,
|
|
232455
232652
|
error: err instanceof Error ? err.message : String(err)
|
|
232456
232653
|
}
|
|
232457
232654
|
});
|
|
232458
232655
|
return cached?.value ?? fallback();
|
|
232656
|
+
} finally {
|
|
232657
|
+
if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
|
|
232459
232658
|
}
|
|
232460
232659
|
}
|
|
232461
232660
|
/**
|
|
@@ -232896,7 +233095,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232896
233095
|
registerPrivacyMaskCap() {
|
|
232897
233096
|
const channel = this.getChannel();
|
|
232898
233097
|
const CAP_NAME = "privacy-mask";
|
|
232899
|
-
const STALE_MS = 1e4;
|
|
232900
233098
|
const refreshFromCamera = async () => {
|
|
232901
233099
|
if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
|
|
232902
233100
|
const promise = (async () => {
|
|
@@ -232949,7 +233147,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232949
233147
|
cap: privacyMaskCapability,
|
|
232950
233148
|
ownDeviceId: this.id,
|
|
232951
233149
|
refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
|
|
232952
|
-
staleMs:
|
|
233150
|
+
staleMs: OPERATOR_WRITTEN_STALE_MS,
|
|
233151
|
+
staleRead: "serve-and-revalidate",
|
|
232953
233152
|
empty: () => ({
|
|
232954
233153
|
enabled: false,
|
|
232955
233154
|
regions: [],
|