@camstack/addon-provider-reolink 1.2.46 → 1.2.47
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 +208 -18
- package/dist/addon.mjs +208 -18
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -19207,6 +19207,12 @@ var CameraStatusSchema = object({
|
|
|
19207
19207
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
19208
19208
|
fetchedAt: number()
|
|
19209
19209
|
});
|
|
19210
|
+
var InferenceDeviceExclusionReasonSchema = _enum([
|
|
19211
|
+
"disabled",
|
|
19212
|
+
"unavailable",
|
|
19213
|
+
"cannot-host-camera-root",
|
|
19214
|
+
"accelerator-preferred"
|
|
19215
|
+
]);
|
|
19210
19216
|
var NodeInferenceDeviceSchema = object({
|
|
19211
19217
|
/** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
|
|
19212
19218
|
key: string(),
|
|
@@ -19237,7 +19243,17 @@ var NodeInferenceDeviceSchema = object({
|
|
|
19237
19243
|
* available per format; this is the stored selection that becomes the
|
|
19238
19244
|
* default for EVERY camera landing on this accelerator.
|
|
19239
19245
|
*/
|
|
19240
|
-
steps: record(string(), DeviceStepConfigSchema).optional()
|
|
19246
|
+
steps: record(string(), DeviceStepConfigSchema).optional(),
|
|
19247
|
+
/**
|
|
19248
|
+
* `null` when the device IS a camera-root candidate on this node; otherwise
|
|
19249
|
+
* the reason the dispatcher drops it. Computed by the SAME
|
|
19250
|
+
* `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
|
|
19251
|
+
* never disagree with the election — deriving it in the UI from
|
|
19252
|
+
* `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
|
|
19253
|
+
* the node's model catalog) and `accelerator-preferred` (needs the node-wide
|
|
19254
|
+
* "an accelerator is serving" predicate).
|
|
19255
|
+
*/
|
|
19256
|
+
exclusion: InferenceDeviceExclusionReasonSchema.nullable()
|
|
19241
19257
|
});
|
|
19242
19258
|
var NodeInferenceDevicesSchema = object({
|
|
19243
19259
|
nodeId: string(),
|
|
@@ -24192,7 +24208,12 @@ var ListResultSchema = object({
|
|
|
24192
24208
|
probedAt: number()
|
|
24193
24209
|
});
|
|
24194
24210
|
var PreferredSchema = LocalInterfaceSchema.nullable();
|
|
24195
|
-
|
|
24211
|
+
/**
|
|
24212
|
+
* Candidate base URL for the SDK to race on connect. Order matters —
|
|
24213
|
+
* the SDK should attempt these top-to-bottom with a short per-candidate
|
|
24214
|
+
* timeout (e.g. 1500ms) and cache the winner for the session.
|
|
24215
|
+
*/
|
|
24216
|
+
var ConnectionEndpointSchema = object({
|
|
24196
24217
|
/** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
|
|
24197
24218
|
label: string(),
|
|
24198
24219
|
/** Fully-formed base URL with scheme + host + port. */
|
|
@@ -24235,7 +24256,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
|
|
|
24235
24256
|
* ordering between polls.
|
|
24236
24257
|
*/
|
|
24237
24258
|
priority: number()
|
|
24238
|
-
})
|
|
24259
|
+
});
|
|
24260
|
+
/**
|
|
24261
|
+
* Where the advertised local port came from. Ordered most → least
|
|
24262
|
+
* authoritative, and the whole point of returning it: a client must be able to
|
|
24263
|
+
* tell a FACT about the hub's socket from an echo of its own guess.
|
|
24264
|
+
*/
|
|
24265
|
+
var LocalPortSourceEnum = _enum([
|
|
24266
|
+
"server-config",
|
|
24267
|
+
"server-env",
|
|
24268
|
+
"caller-hint",
|
|
24269
|
+
"default"
|
|
24270
|
+
]);
|
|
24271
|
+
/** The port every LAN/loopback `baseUrl` in the same result was built with. */
|
|
24272
|
+
var AdvertisedLocalPortSchema = object({
|
|
24273
|
+
port: number().int().min(1).max(65535),
|
|
24274
|
+
source: LocalPortSourceEnum
|
|
24275
|
+
});
|
|
24276
|
+
var GetConnectionEndpointsResultSchema = object({
|
|
24277
|
+
endpoints: array(ConnectionEndpointSchema).readonly(),
|
|
24278
|
+
/**
|
|
24279
|
+
* The port the hub built the LAN/loopback URLs with, and where that number
|
|
24280
|
+
* came from.
|
|
24281
|
+
*
|
|
24282
|
+
* Returned rather than merely applied, because "the URL is right" and "the
|
|
24283
|
+
* client can KNOW the URL is right" are different properties. A client that
|
|
24284
|
+
* only sees a corrected URL cannot distinguish a hub that fixed the port from
|
|
24285
|
+
* a hub that echoed the port the client sent, so it cannot decide whether to
|
|
24286
|
+
* race the candidate or discard it. With `source` it can: anything but
|
|
24287
|
+
* `caller-hint` is the hub's own socket.
|
|
24288
|
+
*
|
|
24289
|
+
* Absent on hubs predating this field — a client that finds it missing is
|
|
24290
|
+
* talking to an echoing hub and must degrade exactly as it does for
|
|
24291
|
+
* `caller-hint`.
|
|
24292
|
+
*/
|
|
24293
|
+
localPort: AdvertisedLocalPortSchema
|
|
24294
|
+
});
|
|
24239
24295
|
/**
|
|
24240
24296
|
* The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
|
|
24241
24297
|
* AUTO (resolved from the candidate ranking at send time); `resolved` reports
|
|
@@ -24256,8 +24312,13 @@ var AllowedAddressesSchema = object({
|
|
|
24256
24312
|
*/
|
|
24257
24313
|
addresses: array(string()).readonly() });
|
|
24258
24314
|
method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
|
|
24259
|
-
/**
|
|
24260
|
-
|
|
24315
|
+
/**
|
|
24316
|
+
* LEGACY HINT — do not send from new code. Kept optional so clients
|
|
24317
|
+
* written against the echoing contract keep working; the hub uses it
|
|
24318
|
+
* only when it cannot read its own port, and says so via
|
|
24319
|
+
* `localPort.source === 'caller-hint'`.
|
|
24320
|
+
*/
|
|
24321
|
+
port: number().int().min(1).max(65535).optional(),
|
|
24261
24322
|
/** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
|
|
24262
24323
|
* candidate. Default `true`. */
|
|
24263
24324
|
includeLoopback: boolean().optional(),
|
|
@@ -30364,11 +30425,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
|
|
|
30364
30425
|
new Set(Object.values(DeviceType));
|
|
30365
30426
|
DeviceFeature.BatteryOperated;
|
|
30366
30427
|
var LAST_FETCHED_FIELD = "lastFetchedAt";
|
|
30428
|
+
/**
|
|
30429
|
+
* How long a bridge stops re-attempting a refresh that did not land.
|
|
30430
|
+
*
|
|
30431
|
+
* Sized against the failure it exists for: a camera whose control plane
|
|
30432
|
+
* is unreachable costs the FULL connect give-up (~3s on Linux when the
|
|
30433
|
+
* neighbour never answers, up to the client's own timeout otherwise) on
|
|
30434
|
+
* every attempt, and a failed refresh never advances `lastFetchedAt`, so
|
|
30435
|
+
* without a cooldown the slice is permanently stale and EVERY read pays
|
|
30436
|
+
* that price. One attempt per minute is enough to notice the camera
|
|
30437
|
+
* coming back; per-read is enough to stall the viewer's first paint.
|
|
30438
|
+
*/
|
|
30439
|
+
var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
|
|
30440
|
+
/**
|
|
30441
|
+
* The freshness window for a cap whose value changes ONLY when WE write it.
|
|
30442
|
+
*
|
|
30443
|
+
* The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
|
|
30444
|
+
* battery level, a day/night state that flips at dusk. It is the wrong number
|
|
30445
|
+
* for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
|
|
30446
|
+
* microphone on) change when an operator changes them, through a `setMask` /
|
|
30447
|
+
* `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
|
|
30448
|
+
* A write is therefore its own invalidation: the window never delays an
|
|
30449
|
+
* operator's own change, however long it is.
|
|
30450
|
+
*
|
|
30451
|
+
* What the window really bounds is the ONE case we do not write: somebody
|
|
30452
|
+
* changing the mask in the vendor app or the camera's web UI. Ten minutes is
|
|
30453
|
+
* the trade — that change is visible within ten minutes, and a fleet of 29
|
|
30454
|
+
* cameras behind a page that polls every 5s costs about 3 camera round trips a
|
|
30455
|
+
* minute instead of 696
|
|
30456
|
+
* ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
|
|
30457
|
+
*
|
|
30458
|
+
* Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
|
|
30459
|
+
* On the awaiting default a window this long does not remove the stall, it
|
|
30460
|
+
* merely makes it rarer and just as long.
|
|
30461
|
+
*/
|
|
30462
|
+
var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
|
|
30367
30463
|
function createRuntimeStateBridge(params) {
|
|
30368
30464
|
const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
|
|
30369
30465
|
const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
|
|
30466
|
+
const staleRead = params.staleRead ?? "await-refresh";
|
|
30370
30467
|
/** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
|
|
30371
30468
|
let missCooldownUntil = 0;
|
|
30469
|
+
/**
|
|
30470
|
+
* The refresh this bridge currently has in the air, if any.
|
|
30471
|
+
*
|
|
30472
|
+
* Providers single-flight their own camera client, so this is not what stops
|
|
30473
|
+
* a second round trip. What it stops is a POLLED reader JOINING one: under
|
|
30474
|
+
* `'serve-and-revalidate'` a read that finds a refresh already outstanding is
|
|
30475
|
+
* answered from the slice at once — even a cold, empty slice, which reports
|
|
30476
|
+
* UNKNOWN, which is the truth about a camera nobody has reached.
|
|
30477
|
+
*
|
|
30478
|
+
* That distinction is the whole cost of an unreachable camera. Measured on
|
|
30479
|
+
* the live hub (device 3629, an offline battery Reolink): its refresh takes
|
|
30480
|
+
* 23.1s to give up, so without this every 5s poll landing inside those 23s
|
|
30481
|
+
* joined the wait and spent the caller's full 1.2s source budget — five
|
|
30482
|
+
* stalled polls per cooldown cycle, for one camera, forever.
|
|
30483
|
+
*/
|
|
30484
|
+
let refreshInFlight = null;
|
|
30372
30485
|
const readFetchedAt = () => {
|
|
30373
30486
|
const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
|
|
30374
30487
|
return typeof value === "number" ? value : 0;
|
|
@@ -30389,14 +30502,14 @@ function createRuntimeStateBridge(params) {
|
|
|
30389
30502
|
}
|
|
30390
30503
|
});
|
|
30391
30504
|
};
|
|
30392
|
-
|
|
30393
|
-
|
|
30394
|
-
|
|
30395
|
-
|
|
30396
|
-
|
|
30397
|
-
|
|
30398
|
-
|
|
30399
|
-
|
|
30505
|
+
/**
|
|
30506
|
+
* One refresh attempt, plus the LANDED check that decides the cooldown.
|
|
30507
|
+
*
|
|
30508
|
+
* @param fetchedAt What `lastFetchedAt` was before the attempt — the only
|
|
30509
|
+
* evidence the bridge has that the refresh persisted
|
|
30510
|
+
* anything, since providers swallow their own camera errors.
|
|
30511
|
+
*/
|
|
30512
|
+
const runRefresh = async (fetchedAt) => {
|
|
30400
30513
|
try {
|
|
30401
30514
|
await refresh();
|
|
30402
30515
|
} catch (err) {
|
|
@@ -30412,6 +30525,41 @@ function createRuntimeStateBridge(params) {
|
|
|
30412
30525
|
}
|
|
30413
30526
|
openMissCooldown(void 0);
|
|
30414
30527
|
};
|
|
30528
|
+
/**
|
|
30529
|
+
* Start a refresh and remember it, at most one at a time.
|
|
30530
|
+
*
|
|
30531
|
+
* It never rejects: under `'serve-and-revalidate'` the caller is answered
|
|
30532
|
+
* from the slice either way, so a floating rejection would take the process
|
|
30533
|
+
* down for a fault the miss cooldown has already recorded and logged.
|
|
30534
|
+
*/
|
|
30535
|
+
const startRefresh = (fetchedAt) => {
|
|
30536
|
+
const existing = refreshInFlight;
|
|
30537
|
+
if (existing !== null) return existing;
|
|
30538
|
+
const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
|
|
30539
|
+
refreshInFlight = null;
|
|
30540
|
+
});
|
|
30541
|
+
refreshInFlight = started;
|
|
30542
|
+
return started;
|
|
30543
|
+
};
|
|
30544
|
+
const ensureFresh = async () => {
|
|
30545
|
+
const slice = runtimeState.getCapState(cap.name);
|
|
30546
|
+
const fetchedAt = readFetchedAt();
|
|
30547
|
+
if (slice && Date.now() - fetchedAt <= staleMs) {
|
|
30548
|
+
missCooldownUntil = 0;
|
|
30549
|
+
return;
|
|
30550
|
+
}
|
|
30551
|
+
if (Date.now() < missCooldownUntil) return;
|
|
30552
|
+
if (staleRead === "serve-and-revalidate") {
|
|
30553
|
+
if (refreshInFlight !== null) return;
|
|
30554
|
+
if (slice && fetchedAt > 0) {
|
|
30555
|
+
startRefresh(fetchedAt);
|
|
30556
|
+
return;
|
|
30557
|
+
}
|
|
30558
|
+
await startRefresh(fetchedAt);
|
|
30559
|
+
return;
|
|
30560
|
+
}
|
|
30561
|
+
await runRefresh(fetchedAt);
|
|
30562
|
+
};
|
|
30415
30563
|
const projectStatus = () => {
|
|
30416
30564
|
const slice = runtimeState.getCapState(cap.name);
|
|
30417
30565
|
if (!slice) return empty();
|
|
@@ -232395,6 +232543,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232395
232543
|
* within a day while removing the per-poll round-trip entirely. */
|
|
232396
232544
|
static CAP_OPTIONS_TTL_MS = 360 * 6e4;
|
|
232397
232545
|
/**
|
|
232546
|
+
* Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
|
|
232547
|
+
*
|
|
232548
|
+
* The same discipline `createRuntimeStateBridge` applies to a refresh that
|
|
232549
|
+
* did not land, for the same reason and against the same measurement. A probe
|
|
232550
|
+
* that fails persists nothing, so without this the next caller probes again —
|
|
232551
|
+
* and on a camera that is merely unreachable each attempt burns the full
|
|
232552
|
+
* connect give-up. Measured on the live hub (2026-08-21, device 3629, a
|
|
232553
|
+
* battery Reolink the registry already calls offline): `getOptions` took
|
|
232554
|
+
* **23.1s on every single call**, while its `getStatus` sibling answered in
|
|
232555
|
+
* 8ms because the bridge had already opened its cooldown. The status side was
|
|
232556
|
+
* bounded and the options side was not, and only one of them was visible.
|
|
232557
|
+
*/
|
|
232558
|
+
capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
|
|
232559
|
+
/**
|
|
232560
|
+
* Per-cap in-flight options probe, so concurrent callers share ONE.
|
|
232561
|
+
*
|
|
232562
|
+
* The cooldown above only opens once a probe has FAILED — and on an
|
|
232563
|
+
* unreachable camera that takes the full connect give-up (23.1s measured on
|
|
232564
|
+
* device 3629). Every poll arriving inside that window used to start its own
|
|
232565
|
+
* probe, so the cooldown never got a chance to bound anything: the fix for
|
|
232566
|
+
* "one dial per poll" has to cover the dial that is still in the air.
|
|
232567
|
+
*
|
|
232568
|
+
* A caller that finds one here is SERVED, not queued behind it — this method
|
|
232569
|
+
* sits on a polled path, and waiting for a probe that is not coming back is
|
|
232570
|
+
* how one unreachable camera stalls every poll it overlaps.
|
|
232571
|
+
*/
|
|
232572
|
+
capOptionsInFlight = /* @__PURE__ */ new Map();
|
|
232573
|
+
/**
|
|
232398
232574
|
* Read a persisted `getOptions` descriptor, validated with the cap's
|
|
232399
232575
|
* OWN Zod schema. Validation (not a cast) is what makes the
|
|
232400
232576
|
* `z.unknown()` blob type-safe: an entry written by an older addon
|
|
@@ -232452,7 +232628,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232452
232628
|
async resolveCapOptions(params) {
|
|
232453
232629
|
const { capName, schema, probe, fallback } = params;
|
|
232454
232630
|
const cached = this.readCapOptionsCache(capName, schema);
|
|
232455
|
-
if (
|
|
232631
|
+
if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
|
|
232632
|
+
if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
|
|
232633
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232634
|
+
return cached.value;
|
|
232635
|
+
}
|
|
232456
232636
|
if (this.isBattery && this.sleeping) {
|
|
232457
232637
|
this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
|
|
232458
232638
|
tags: { deviceId: this.id },
|
|
@@ -232463,19 +232643,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232463
232643
|
});
|
|
232464
232644
|
return cached?.value ?? fallback();
|
|
232465
232645
|
}
|
|
232646
|
+
const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
|
|
232647
|
+
if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
|
|
232648
|
+
const started = probe();
|
|
232649
|
+
this.capOptionsInFlight.set(capName, started);
|
|
232466
232650
|
try {
|
|
232467
|
-
const value = await
|
|
232651
|
+
const value = await started;
|
|
232468
232652
|
await this.persistCapOptions(capName, value);
|
|
232653
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232469
232654
|
return value;
|
|
232470
232655
|
} catch (err) {
|
|
232471
|
-
this.
|
|
232656
|
+
this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
|
|
232657
|
+
this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
|
|
232472
232658
|
tags: { deviceId: this.id },
|
|
232473
232659
|
meta: {
|
|
232474
232660
|
capName,
|
|
232661
|
+
cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
|
|
232662
|
+
hasCache: cached !== null,
|
|
232475
232663
|
error: err instanceof Error ? err.message : String(err)
|
|
232476
232664
|
}
|
|
232477
232665
|
});
|
|
232478
232666
|
return cached?.value ?? fallback();
|
|
232667
|
+
} finally {
|
|
232668
|
+
if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
|
|
232479
232669
|
}
|
|
232480
232670
|
}
|
|
232481
232671
|
/**
|
|
@@ -232916,7 +233106,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232916
233106
|
registerPrivacyMaskCap() {
|
|
232917
233107
|
const channel = this.getChannel();
|
|
232918
233108
|
const CAP_NAME = "privacy-mask";
|
|
232919
|
-
const STALE_MS = 1e4;
|
|
232920
233109
|
const refreshFromCamera = async () => {
|
|
232921
233110
|
if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
|
|
232922
233111
|
const promise = (async () => {
|
|
@@ -232969,7 +233158,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232969
233158
|
cap: privacyMaskCapability,
|
|
232970
233159
|
ownDeviceId: this.id,
|
|
232971
233160
|
refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
|
|
232972
|
-
staleMs:
|
|
233161
|
+
staleMs: OPERATOR_WRITTEN_STALE_MS,
|
|
233162
|
+
staleRead: "serve-and-revalidate",
|
|
232973
233163
|
empty: () => ({
|
|
232974
233164
|
enabled: false,
|
|
232975
233165
|
regions: [],
|
package/dist/addon.mjs
CHANGED
|
@@ -19202,6 +19202,12 @@ var CameraStatusSchema = object({
|
|
|
19202
19202
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
19203
19203
|
fetchedAt: number()
|
|
19204
19204
|
});
|
|
19205
|
+
var InferenceDeviceExclusionReasonSchema = _enum([
|
|
19206
|
+
"disabled",
|
|
19207
|
+
"unavailable",
|
|
19208
|
+
"cannot-host-camera-root",
|
|
19209
|
+
"accelerator-preferred"
|
|
19210
|
+
]);
|
|
19205
19211
|
var NodeInferenceDeviceSchema = object({
|
|
19206
19212
|
/** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
|
|
19207
19213
|
key: string(),
|
|
@@ -19232,7 +19238,17 @@ var NodeInferenceDeviceSchema = object({
|
|
|
19232
19238
|
* available per format; this is the stored selection that becomes the
|
|
19233
19239
|
* default for EVERY camera landing on this accelerator.
|
|
19234
19240
|
*/
|
|
19235
|
-
steps: record(string(), DeviceStepConfigSchema).optional()
|
|
19241
|
+
steps: record(string(), DeviceStepConfigSchema).optional(),
|
|
19242
|
+
/**
|
|
19243
|
+
* `null` when the device IS a camera-root candidate on this node; otherwise
|
|
19244
|
+
* the reason the dispatcher drops it. Computed by the SAME
|
|
19245
|
+
* `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
|
|
19246
|
+
* never disagree with the election — deriving it in the UI from
|
|
19247
|
+
* `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
|
|
19248
|
+
* the node's model catalog) and `accelerator-preferred` (needs the node-wide
|
|
19249
|
+
* "an accelerator is serving" predicate).
|
|
19250
|
+
*/
|
|
19251
|
+
exclusion: InferenceDeviceExclusionReasonSchema.nullable()
|
|
19236
19252
|
});
|
|
19237
19253
|
var NodeInferenceDevicesSchema = object({
|
|
19238
19254
|
nodeId: string(),
|
|
@@ -24187,7 +24203,12 @@ var ListResultSchema = object({
|
|
|
24187
24203
|
probedAt: number()
|
|
24188
24204
|
});
|
|
24189
24205
|
var PreferredSchema = LocalInterfaceSchema.nullable();
|
|
24190
|
-
|
|
24206
|
+
/**
|
|
24207
|
+
* Candidate base URL for the SDK to race on connect. Order matters —
|
|
24208
|
+
* the SDK should attempt these top-to-bottom with a short per-candidate
|
|
24209
|
+
* timeout (e.g. 1500ms) and cache the winner for the session.
|
|
24210
|
+
*/
|
|
24211
|
+
var ConnectionEndpointSchema = object({
|
|
24191
24212
|
/** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
|
|
24192
24213
|
label: string(),
|
|
24193
24214
|
/** Fully-formed base URL with scheme + host + port. */
|
|
@@ -24230,7 +24251,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
|
|
|
24230
24251
|
* ordering between polls.
|
|
24231
24252
|
*/
|
|
24232
24253
|
priority: number()
|
|
24233
|
-
})
|
|
24254
|
+
});
|
|
24255
|
+
/**
|
|
24256
|
+
* Where the advertised local port came from. Ordered most → least
|
|
24257
|
+
* authoritative, and the whole point of returning it: a client must be able to
|
|
24258
|
+
* tell a FACT about the hub's socket from an echo of its own guess.
|
|
24259
|
+
*/
|
|
24260
|
+
var LocalPortSourceEnum = _enum([
|
|
24261
|
+
"server-config",
|
|
24262
|
+
"server-env",
|
|
24263
|
+
"caller-hint",
|
|
24264
|
+
"default"
|
|
24265
|
+
]);
|
|
24266
|
+
/** The port every LAN/loopback `baseUrl` in the same result was built with. */
|
|
24267
|
+
var AdvertisedLocalPortSchema = object({
|
|
24268
|
+
port: number().int().min(1).max(65535),
|
|
24269
|
+
source: LocalPortSourceEnum
|
|
24270
|
+
});
|
|
24271
|
+
var GetConnectionEndpointsResultSchema = object({
|
|
24272
|
+
endpoints: array(ConnectionEndpointSchema).readonly(),
|
|
24273
|
+
/**
|
|
24274
|
+
* The port the hub built the LAN/loopback URLs with, and where that number
|
|
24275
|
+
* came from.
|
|
24276
|
+
*
|
|
24277
|
+
* Returned rather than merely applied, because "the URL is right" and "the
|
|
24278
|
+
* client can KNOW the URL is right" are different properties. A client that
|
|
24279
|
+
* only sees a corrected URL cannot distinguish a hub that fixed the port from
|
|
24280
|
+
* a hub that echoed the port the client sent, so it cannot decide whether to
|
|
24281
|
+
* race the candidate or discard it. With `source` it can: anything but
|
|
24282
|
+
* `caller-hint` is the hub's own socket.
|
|
24283
|
+
*
|
|
24284
|
+
* Absent on hubs predating this field — a client that finds it missing is
|
|
24285
|
+
* talking to an echoing hub and must degrade exactly as it does for
|
|
24286
|
+
* `caller-hint`.
|
|
24287
|
+
*/
|
|
24288
|
+
localPort: AdvertisedLocalPortSchema
|
|
24289
|
+
});
|
|
24234
24290
|
/**
|
|
24235
24291
|
* The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
|
|
24236
24292
|
* AUTO (resolved from the candidate ranking at send time); `resolved` reports
|
|
@@ -24251,8 +24307,13 @@ var AllowedAddressesSchema = object({
|
|
|
24251
24307
|
*/
|
|
24252
24308
|
addresses: array(string()).readonly() });
|
|
24253
24309
|
method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
|
|
24254
|
-
/**
|
|
24255
|
-
|
|
24310
|
+
/**
|
|
24311
|
+
* LEGACY HINT — do not send from new code. Kept optional so clients
|
|
24312
|
+
* written against the echoing contract keep working; the hub uses it
|
|
24313
|
+
* only when it cannot read its own port, and says so via
|
|
24314
|
+
* `localPort.source === 'caller-hint'`.
|
|
24315
|
+
*/
|
|
24316
|
+
port: number().int().min(1).max(65535).optional(),
|
|
24256
24317
|
/** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
|
|
24257
24318
|
* candidate. Default `true`. */
|
|
24258
24319
|
includeLoopback: boolean().optional(),
|
|
@@ -30359,11 +30420,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
|
|
|
30359
30420
|
new Set(Object.values(DeviceType));
|
|
30360
30421
|
DeviceFeature.BatteryOperated;
|
|
30361
30422
|
var LAST_FETCHED_FIELD = "lastFetchedAt";
|
|
30423
|
+
/**
|
|
30424
|
+
* How long a bridge stops re-attempting a refresh that did not land.
|
|
30425
|
+
*
|
|
30426
|
+
* Sized against the failure it exists for: a camera whose control plane
|
|
30427
|
+
* is unreachable costs the FULL connect give-up (~3s on Linux when the
|
|
30428
|
+
* neighbour never answers, up to the client's own timeout otherwise) on
|
|
30429
|
+
* every attempt, and a failed refresh never advances `lastFetchedAt`, so
|
|
30430
|
+
* without a cooldown the slice is permanently stale and EVERY read pays
|
|
30431
|
+
* that price. One attempt per minute is enough to notice the camera
|
|
30432
|
+
* coming back; per-read is enough to stall the viewer's first paint.
|
|
30433
|
+
*/
|
|
30434
|
+
var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
|
|
30435
|
+
/**
|
|
30436
|
+
* The freshness window for a cap whose value changes ONLY when WE write it.
|
|
30437
|
+
*
|
|
30438
|
+
* The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
|
|
30439
|
+
* battery level, a day/night state that flips at dusk. It is the wrong number
|
|
30440
|
+
* for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
|
|
30441
|
+
* microphone on) change when an operator changes them, through a `setMask` /
|
|
30442
|
+
* `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
|
|
30443
|
+
* A write is therefore its own invalidation: the window never delays an
|
|
30444
|
+
* operator's own change, however long it is.
|
|
30445
|
+
*
|
|
30446
|
+
* What the window really bounds is the ONE case we do not write: somebody
|
|
30447
|
+
* changing the mask in the vendor app or the camera's web UI. Ten minutes is
|
|
30448
|
+
* the trade — that change is visible within ten minutes, and a fleet of 29
|
|
30449
|
+
* cameras behind a page that polls every 5s costs about 3 camera round trips a
|
|
30450
|
+
* minute instead of 696
|
|
30451
|
+
* ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
|
|
30452
|
+
*
|
|
30453
|
+
* Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
|
|
30454
|
+
* On the awaiting default a window this long does not remove the stall, it
|
|
30455
|
+
* merely makes it rarer and just as long.
|
|
30456
|
+
*/
|
|
30457
|
+
var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
|
|
30362
30458
|
function createRuntimeStateBridge(params) {
|
|
30363
30459
|
const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
|
|
30364
30460
|
const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
|
|
30461
|
+
const staleRead = params.staleRead ?? "await-refresh";
|
|
30365
30462
|
/** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
|
|
30366
30463
|
let missCooldownUntil = 0;
|
|
30464
|
+
/**
|
|
30465
|
+
* The refresh this bridge currently has in the air, if any.
|
|
30466
|
+
*
|
|
30467
|
+
* Providers single-flight their own camera client, so this is not what stops
|
|
30468
|
+
* a second round trip. What it stops is a POLLED reader JOINING one: under
|
|
30469
|
+
* `'serve-and-revalidate'` a read that finds a refresh already outstanding is
|
|
30470
|
+
* answered from the slice at once — even a cold, empty slice, which reports
|
|
30471
|
+
* UNKNOWN, which is the truth about a camera nobody has reached.
|
|
30472
|
+
*
|
|
30473
|
+
* That distinction is the whole cost of an unreachable camera. Measured on
|
|
30474
|
+
* the live hub (device 3629, an offline battery Reolink): its refresh takes
|
|
30475
|
+
* 23.1s to give up, so without this every 5s poll landing inside those 23s
|
|
30476
|
+
* joined the wait and spent the caller's full 1.2s source budget — five
|
|
30477
|
+
* stalled polls per cooldown cycle, for one camera, forever.
|
|
30478
|
+
*/
|
|
30479
|
+
let refreshInFlight = null;
|
|
30367
30480
|
const readFetchedAt = () => {
|
|
30368
30481
|
const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
|
|
30369
30482
|
return typeof value === "number" ? value : 0;
|
|
@@ -30384,14 +30497,14 @@ function createRuntimeStateBridge(params) {
|
|
|
30384
30497
|
}
|
|
30385
30498
|
});
|
|
30386
30499
|
};
|
|
30387
|
-
|
|
30388
|
-
|
|
30389
|
-
|
|
30390
|
-
|
|
30391
|
-
|
|
30392
|
-
|
|
30393
|
-
|
|
30394
|
-
|
|
30500
|
+
/**
|
|
30501
|
+
* One refresh attempt, plus the LANDED check that decides the cooldown.
|
|
30502
|
+
*
|
|
30503
|
+
* @param fetchedAt What `lastFetchedAt` was before the attempt — the only
|
|
30504
|
+
* evidence the bridge has that the refresh persisted
|
|
30505
|
+
* anything, since providers swallow their own camera errors.
|
|
30506
|
+
*/
|
|
30507
|
+
const runRefresh = async (fetchedAt) => {
|
|
30395
30508
|
try {
|
|
30396
30509
|
await refresh();
|
|
30397
30510
|
} catch (err) {
|
|
@@ -30407,6 +30520,41 @@ function createRuntimeStateBridge(params) {
|
|
|
30407
30520
|
}
|
|
30408
30521
|
openMissCooldown(void 0);
|
|
30409
30522
|
};
|
|
30523
|
+
/**
|
|
30524
|
+
* Start a refresh and remember it, at most one at a time.
|
|
30525
|
+
*
|
|
30526
|
+
* It never rejects: under `'serve-and-revalidate'` the caller is answered
|
|
30527
|
+
* from the slice either way, so a floating rejection would take the process
|
|
30528
|
+
* down for a fault the miss cooldown has already recorded and logged.
|
|
30529
|
+
*/
|
|
30530
|
+
const startRefresh = (fetchedAt) => {
|
|
30531
|
+
const existing = refreshInFlight;
|
|
30532
|
+
if (existing !== null) return existing;
|
|
30533
|
+
const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
|
|
30534
|
+
refreshInFlight = null;
|
|
30535
|
+
});
|
|
30536
|
+
refreshInFlight = started;
|
|
30537
|
+
return started;
|
|
30538
|
+
};
|
|
30539
|
+
const ensureFresh = async () => {
|
|
30540
|
+
const slice = runtimeState.getCapState(cap.name);
|
|
30541
|
+
const fetchedAt = readFetchedAt();
|
|
30542
|
+
if (slice && Date.now() - fetchedAt <= staleMs) {
|
|
30543
|
+
missCooldownUntil = 0;
|
|
30544
|
+
return;
|
|
30545
|
+
}
|
|
30546
|
+
if (Date.now() < missCooldownUntil) return;
|
|
30547
|
+
if (staleRead === "serve-and-revalidate") {
|
|
30548
|
+
if (refreshInFlight !== null) return;
|
|
30549
|
+
if (slice && fetchedAt > 0) {
|
|
30550
|
+
startRefresh(fetchedAt);
|
|
30551
|
+
return;
|
|
30552
|
+
}
|
|
30553
|
+
await startRefresh(fetchedAt);
|
|
30554
|
+
return;
|
|
30555
|
+
}
|
|
30556
|
+
await runRefresh(fetchedAt);
|
|
30557
|
+
};
|
|
30410
30558
|
const projectStatus = () => {
|
|
30411
30559
|
const slice = runtimeState.getCapState(cap.name);
|
|
30412
30560
|
if (!slice) return empty();
|
|
@@ -232375,6 +232523,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232375
232523
|
* within a day while removing the per-poll round-trip entirely. */
|
|
232376
232524
|
static CAP_OPTIONS_TTL_MS = 360 * 6e4;
|
|
232377
232525
|
/**
|
|
232526
|
+
* Per-cap epoch-ms until which a FAILED options probe is not re-attempted.
|
|
232527
|
+
*
|
|
232528
|
+
* The same discipline `createRuntimeStateBridge` applies to a refresh that
|
|
232529
|
+
* did not land, for the same reason and against the same measurement. A probe
|
|
232530
|
+
* that fails persists nothing, so without this the next caller probes again —
|
|
232531
|
+
* and on a camera that is merely unreachable each attempt burns the full
|
|
232532
|
+
* connect give-up. Measured on the live hub (2026-08-21, device 3629, a
|
|
232533
|
+
* battery Reolink the registry already calls offline): `getOptions` took
|
|
232534
|
+
* **23.1s on every single call**, while its `getStatus` sibling answered in
|
|
232535
|
+
* 8ms because the bridge had already opened its cooldown. The status side was
|
|
232536
|
+
* bounded and the options side was not, and only one of them was visible.
|
|
232537
|
+
*/
|
|
232538
|
+
capOptionsMissCooldownUntil = /* @__PURE__ */ new Map();
|
|
232539
|
+
/**
|
|
232540
|
+
* Per-cap in-flight options probe, so concurrent callers share ONE.
|
|
232541
|
+
*
|
|
232542
|
+
* The cooldown above only opens once a probe has FAILED — and on an
|
|
232543
|
+
* unreachable camera that takes the full connect give-up (23.1s measured on
|
|
232544
|
+
* device 3629). Every poll arriving inside that window used to start its own
|
|
232545
|
+
* probe, so the cooldown never got a chance to bound anything: the fix for
|
|
232546
|
+
* "one dial per poll" has to cover the dial that is still in the air.
|
|
232547
|
+
*
|
|
232548
|
+
* A caller that finds one here is SERVED, not queued behind it — this method
|
|
232549
|
+
* sits on a polled path, and waiting for a probe that is not coming back is
|
|
232550
|
+
* how one unreachable camera stalls every poll it overlaps.
|
|
232551
|
+
*/
|
|
232552
|
+
capOptionsInFlight = /* @__PURE__ */ new Map();
|
|
232553
|
+
/**
|
|
232378
232554
|
* Read a persisted `getOptions` descriptor, validated with the cap's
|
|
232379
232555
|
* OWN Zod schema. Validation (not a cast) is what makes the
|
|
232380
232556
|
* `z.unknown()` blob type-safe: an entry written by an older addon
|
|
@@ -232432,7 +232608,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232432
232608
|
async resolveCapOptions(params) {
|
|
232433
232609
|
const { capName, schema, probe, fallback } = params;
|
|
232434
232610
|
const cached = this.readCapOptionsCache(capName, schema);
|
|
232435
|
-
if (
|
|
232611
|
+
if (this.capOptionsInFlight.has(capName)) return cached?.value ?? fallback();
|
|
232612
|
+
if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) {
|
|
232613
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232614
|
+
return cached.value;
|
|
232615
|
+
}
|
|
232436
232616
|
if (this.isBattery && this.sleeping) {
|
|
232437
232617
|
this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
|
|
232438
232618
|
tags: { deviceId: this.id },
|
|
@@ -232443,19 +232623,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232443
232623
|
});
|
|
232444
232624
|
return cached?.value ?? fallback();
|
|
232445
232625
|
}
|
|
232626
|
+
const cooldownUntil = this.capOptionsMissCooldownUntil.get(capName) ?? 0;
|
|
232627
|
+
if (Date.now() < cooldownUntil) return cached?.value ?? fallback();
|
|
232628
|
+
const started = probe();
|
|
232629
|
+
this.capOptionsInFlight.set(capName, started);
|
|
232446
232630
|
try {
|
|
232447
|
-
const value = await
|
|
232631
|
+
const value = await started;
|
|
232448
232632
|
await this.persistCapOptions(capName, value);
|
|
232633
|
+
this.capOptionsMissCooldownUntil.delete(capName);
|
|
232449
232634
|
return value;
|
|
232450
232635
|
} catch (err) {
|
|
232451
|
-
this.
|
|
232636
|
+
this.capOptionsMissCooldownUntil.set(capName, Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS);
|
|
232637
|
+
this.ctx.logger.warn("cap options probe failed — serving cache/fallback and not re-probing for a minute", {
|
|
232452
232638
|
tags: { deviceId: this.id },
|
|
232453
232639
|
meta: {
|
|
232454
232640
|
capName,
|
|
232641
|
+
cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
|
|
232642
|
+
hasCache: cached !== null,
|
|
232455
232643
|
error: err instanceof Error ? err.message : String(err)
|
|
232456
232644
|
}
|
|
232457
232645
|
});
|
|
232458
232646
|
return cached?.value ?? fallback();
|
|
232647
|
+
} finally {
|
|
232648
|
+
if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
|
|
232459
232649
|
}
|
|
232460
232650
|
}
|
|
232461
232651
|
/**
|
|
@@ -232896,7 +233086,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232896
233086
|
registerPrivacyMaskCap() {
|
|
232897
233087
|
const channel = this.getChannel();
|
|
232898
233088
|
const CAP_NAME = "privacy-mask";
|
|
232899
|
-
const STALE_MS = 1e4;
|
|
232900
233089
|
const refreshFromCamera = async () => {
|
|
232901
233090
|
if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
|
|
232902
233091
|
const promise = (async () => {
|
|
@@ -232949,7 +233138,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
232949
233138
|
cap: privacyMaskCapability,
|
|
232950
233139
|
ownDeviceId: this.id,
|
|
232951
233140
|
refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
|
|
232952
|
-
staleMs:
|
|
233141
|
+
staleMs: OPERATOR_WRITTEN_STALE_MS,
|
|
233142
|
+
staleRead: "serve-and-revalidate",
|
|
232953
233143
|
empty: () => ({
|
|
232954
233144
|
enabled: false,
|
|
232955
233145
|
regions: [],
|