@camstack/addon-provider-amcrest 0.2.25 → 0.2.27

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.
Files changed (3) hide show
  1. package/dist/addon.js +161 -16
  2. package/dist/addon.mjs +161 -16
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -4,7 +4,7 @@ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).expor
4
4
  //#endregion
5
5
  let node_crypto = require("node:crypto");
6
6
  let node_os = require("node:os");
7
- //#region ../types/dist/event-category-XfKNtfCc.mjs
7
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
8
8
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
9
9
  EventCategory["SystemBoot"] = "system.boot";
10
10
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -20,6 +20,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
20
20
  */
21
21
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
22
22
  /**
23
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
24
+ * Emitted only when the material on disk actually changed, so an
25
+ * operator who trusted the old certificate by hand is told rather than
26
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
27
+ *
28
+ * Rule: docs/decisions/adr-0227-*.md
29
+ */
30
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
31
+ /**
23
32
  * A newer addon or server-root package version was found by the
24
33
  * authoritative registry check. Emitted once when any observed
25
34
  * `latestVersion` changes (or a package/node first appears behind);
@@ -18974,6 +18983,12 @@ var CameraStatusSchema = object({
18974
18983
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
18975
18984
  fetchedAt: number()
18976
18985
  });
18986
+ var InferenceDeviceExclusionReasonSchema = _enum([
18987
+ "disabled",
18988
+ "unavailable",
18989
+ "cannot-host-camera-root",
18990
+ "accelerator-preferred"
18991
+ ]);
18977
18992
  var NodeInferenceDeviceSchema = object({
18978
18993
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
18979
18994
  key: string(),
@@ -19004,7 +19019,17 @@ var NodeInferenceDeviceSchema = object({
19004
19019
  * available per format; this is the stored selection that becomes the
19005
19020
  * default for EVERY camera landing on this accelerator.
19006
19021
  */
19007
- steps: record(string(), DeviceStepConfigSchema).optional()
19022
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19023
+ /**
19024
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19025
+ * the reason the dispatcher drops it. Computed by the SAME
19026
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19027
+ * never disagree with the election — deriving it in the UI from
19028
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19029
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19030
+ * "an accelerator is serving" predicate).
19031
+ */
19032
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19008
19033
  });
19009
19034
  var NodeInferenceDevicesSchema = object({
19010
19035
  nodeId: string(),
@@ -23959,7 +23984,12 @@ var ListResultSchema = object({
23959
23984
  probedAt: number()
23960
23985
  });
23961
23986
  var PreferredSchema = LocalInterfaceSchema.nullable();
23962
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
23987
+ /**
23988
+ * Candidate base URL for the SDK to race on connect. Order matters —
23989
+ * the SDK should attempt these top-to-bottom with a short per-candidate
23990
+ * timeout (e.g. 1500ms) and cache the winner for the session.
23991
+ */
23992
+ var ConnectionEndpointSchema = object({
23963
23993
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
23964
23994
  label: string(),
23965
23995
  /** Fully-formed base URL with scheme + host + port. */
@@ -24002,7 +24032,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24002
24032
  * ordering between polls.
24003
24033
  */
24004
24034
  priority: number()
24005
- })).readonly() });
24035
+ });
24036
+ /**
24037
+ * Where the advertised local port came from. Ordered most → least
24038
+ * authoritative, and the whole point of returning it: a client must be able to
24039
+ * tell a FACT about the hub's socket from an echo of its own guess.
24040
+ */
24041
+ var LocalPortSourceEnum = _enum([
24042
+ "server-config",
24043
+ "server-env",
24044
+ "caller-hint",
24045
+ "default"
24046
+ ]);
24047
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24048
+ var AdvertisedLocalPortSchema = object({
24049
+ port: number().int().min(1).max(65535),
24050
+ source: LocalPortSourceEnum
24051
+ });
24052
+ var GetConnectionEndpointsResultSchema = object({
24053
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24054
+ /**
24055
+ * The port the hub built the LAN/loopback URLs with, and where that number
24056
+ * came from.
24057
+ *
24058
+ * Returned rather than merely applied, because "the URL is right" and "the
24059
+ * client can KNOW the URL is right" are different properties. A client that
24060
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24061
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24062
+ * race the candidate or discard it. With `source` it can: anything but
24063
+ * `caller-hint` is the hub's own socket.
24064
+ *
24065
+ * Absent on hubs predating this field — a client that finds it missing is
24066
+ * talking to an echoing hub and must degrade exactly as it does for
24067
+ * `caller-hint`.
24068
+ */
24069
+ localPort: AdvertisedLocalPortSchema
24070
+ });
24006
24071
  /**
24007
24072
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24008
24073
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24023,8 +24088,13 @@ var AllowedAddressesSchema = object({
24023
24088
  */
24024
24089
  addresses: array(string()).readonly() });
24025
24090
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24026
- /** Local hub HTTP port to use in base URLs. */
24027
- port: number().int().min(1).max(65535),
24091
+ /**
24092
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24093
+ * written against the echoing contract keep working; the hub uses it
24094
+ * only when it cannot read its own port, and says so via
24095
+ * `localPort.source === 'caller-hint'`.
24096
+ */
24097
+ port: number().int().min(1).max(65535).optional(),
24028
24098
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24029
24099
  * candidate. Default `true`. */
24030
24100
  includeLoopback: boolean().optional(),
@@ -30183,11 +30253,51 @@ function startReachabilityPoll(options) {
30183
30253
  } };
30184
30254
  }
30185
30255
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30256
+ /**
30257
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30258
+ *
30259
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30260
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30261
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30262
+ * microphone on) change when an operator changes them, through a `setMask` /
30263
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30264
+ * A write is therefore its own invalidation: the window never delays an
30265
+ * operator's own change, however long it is.
30266
+ *
30267
+ * What the window really bounds is the ONE case we do not write: somebody
30268
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30269
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30270
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30271
+ * minute instead of 696
30272
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30273
+ *
30274
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30275
+ * On the awaiting default a window this long does not remove the stall, it
30276
+ * merely makes it rarer and just as long.
30277
+ */
30278
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30186
30279
  function createRuntimeStateBridge(params) {
30187
30280
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30188
30281
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30282
+ const staleRead = params.staleRead ?? "await-refresh";
30189
30283
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30190
30284
  let missCooldownUntil = 0;
30285
+ /**
30286
+ * The refresh this bridge currently has in the air, if any.
30287
+ *
30288
+ * Providers single-flight their own camera client, so this is not what stops
30289
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30290
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30291
+ * answered from the slice at once — even a cold, empty slice, which reports
30292
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30293
+ *
30294
+ * That distinction is the whole cost of an unreachable camera. Measured on
30295
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30296
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30297
+ * joined the wait and spent the caller's full 1.2s source budget — five
30298
+ * stalled polls per cooldown cycle, for one camera, forever.
30299
+ */
30300
+ let refreshInFlight = null;
30191
30301
  const readFetchedAt = () => {
30192
30302
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30193
30303
  return typeof value === "number" ? value : 0;
@@ -30208,14 +30318,14 @@ function createRuntimeStateBridge(params) {
30208
30318
  }
30209
30319
  });
30210
30320
  };
30211
- const ensureFresh = async () => {
30212
- const slice = runtimeState.getCapState(cap.name);
30213
- const fetchedAt = readFetchedAt();
30214
- if (slice && Date.now() - fetchedAt <= staleMs) {
30215
- missCooldownUntil = 0;
30216
- return;
30217
- }
30218
- if (Date.now() < missCooldownUntil) return;
30321
+ /**
30322
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30323
+ *
30324
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30325
+ * evidence the bridge has that the refresh persisted
30326
+ * anything, since providers swallow their own camera errors.
30327
+ */
30328
+ const runRefresh = async (fetchedAt) => {
30219
30329
  try {
30220
30330
  await refresh();
30221
30331
  } catch (err) {
@@ -30231,6 +30341,41 @@ function createRuntimeStateBridge(params) {
30231
30341
  }
30232
30342
  openMissCooldown(void 0);
30233
30343
  };
30344
+ /**
30345
+ * Start a refresh and remember it, at most one at a time.
30346
+ *
30347
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30348
+ * from the slice either way, so a floating rejection would take the process
30349
+ * down for a fault the miss cooldown has already recorded and logged.
30350
+ */
30351
+ const startRefresh = (fetchedAt) => {
30352
+ const existing = refreshInFlight;
30353
+ if (existing !== null) return existing;
30354
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30355
+ refreshInFlight = null;
30356
+ });
30357
+ refreshInFlight = started;
30358
+ return started;
30359
+ };
30360
+ const ensureFresh = async () => {
30361
+ const slice = runtimeState.getCapState(cap.name);
30362
+ const fetchedAt = readFetchedAt();
30363
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30364
+ missCooldownUntil = 0;
30365
+ return;
30366
+ }
30367
+ if (Date.now() < missCooldownUntil) return;
30368
+ if (staleRead === "serve-and-revalidate") {
30369
+ if (refreshInFlight !== null) return;
30370
+ if (slice && fetchedAt > 0) {
30371
+ startRefresh(fetchedAt);
30372
+ return;
30373
+ }
30374
+ await startRefresh(fetchedAt);
30375
+ return;
30376
+ }
30377
+ await runRefresh(fetchedAt);
30378
+ };
30234
30379
  const projectStatus = () => {
30235
30380
  const slice = runtimeState.getCapState(cap.name);
30236
30381
  if (!slice) return empty();
@@ -39455,7 +39600,6 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
39455
39600
  static PRIVACY_COORD_MAX = 8191;
39456
39601
  registerPrivacyMaskCap() {
39457
39602
  const CAP_NAME = "privacy-mask";
39458
- const STALE_MS = 1e4;
39459
39603
  const maxRegions = AmcrestCamera.PRIVACY_MASK_MAX_REGIONS;
39460
39604
  const coordMax = AmcrestCamera.PRIVACY_COORD_MAX;
39461
39605
  const refreshFromCamera = async () => {
@@ -39510,7 +39654,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
39510
39654
  cap: privacyMaskCapability,
39511
39655
  ownDeviceId: this.id,
39512
39656
  refresh: refreshFromCamera,
39513
- staleMs: STALE_MS,
39657
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
39658
+ staleRead: "serve-and-revalidate",
39514
39659
  logger: this.ctx.logger,
39515
39660
  empty: () => ({
39516
39661
  enabled: false,
package/dist/addon.mjs CHANGED
@@ -5,7 +5,7 @@ import { networkInterfaces } from "node:os";
5
5
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
6
6
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
7
7
  //#endregion
8
- //#region ../types/dist/event-category-XfKNtfCc.mjs
8
+ //#region ../types/dist/event-category-CIa_iT6b.mjs
9
9
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
10
10
  EventCategory["SystemBoot"] = "system.boot";
11
11
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -21,6 +21,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
21
21
  */
22
22
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
23
23
  /**
24
+ * The hub reissued its own TLS certificate at boot (`ensureTlsCert`).
25
+ * Emitted only when the material on disk actually changed, so an
26
+ * operator who trusted the old certificate by hand is told rather than
27
+ * discovering it as a browser error. Payload `TlsCertChangedPayload`.
28
+ *
29
+ * Rule: docs/decisions/adr-0227-*.md
30
+ */
31
+ EventCategory["SystemTlsCertChanged"] = "system.tls-cert-changed";
32
+ /**
24
33
  * A newer addon or server-root package version was found by the
25
34
  * authoritative registry check. Emitted once when any observed
26
35
  * `latestVersion` changes (or a package/node first appears behind);
@@ -18975,6 +18984,12 @@ var CameraStatusSchema = object({
18975
18984
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
18976
18985
  fetchedAt: number()
18977
18986
  });
18987
+ var InferenceDeviceExclusionReasonSchema = _enum([
18988
+ "disabled",
18989
+ "unavailable",
18990
+ "cannot-host-camera-root",
18991
+ "accelerator-preferred"
18992
+ ]);
18978
18993
  var NodeInferenceDeviceSchema = object({
18979
18994
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
18980
18995
  key: string(),
@@ -19005,7 +19020,17 @@ var NodeInferenceDeviceSchema = object({
19005
19020
  * available per format; this is the stored selection that becomes the
19006
19021
  * default for EVERY camera landing on this accelerator.
19007
19022
  */
19008
- steps: record(string(), DeviceStepConfigSchema).optional()
19023
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19024
+ /**
19025
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19026
+ * the reason the dispatcher drops it. Computed by the SAME
19027
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19028
+ * never disagree with the election — deriving it in the UI from
19029
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19030
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19031
+ * "an accelerator is serving" predicate).
19032
+ */
19033
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19009
19034
  });
19010
19035
  var NodeInferenceDevicesSchema = object({
19011
19036
  nodeId: string(),
@@ -23960,7 +23985,12 @@ var ListResultSchema = object({
23960
23985
  probedAt: number()
23961
23986
  });
23962
23987
  var PreferredSchema = LocalInterfaceSchema.nullable();
23963
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
23988
+ /**
23989
+ * Candidate base URL for the SDK to race on connect. Order matters —
23990
+ * the SDK should attempt these top-to-bottom with a short per-candidate
23991
+ * timeout (e.g. 1500ms) and cache the winner for the session.
23992
+ */
23993
+ var ConnectionEndpointSchema = object({
23964
23994
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
23965
23995
  label: string(),
23966
23996
  /** Fully-formed base URL with scheme + host + port. */
@@ -24003,7 +24033,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24003
24033
  * ordering between polls.
24004
24034
  */
24005
24035
  priority: number()
24006
- })).readonly() });
24036
+ });
24037
+ /**
24038
+ * Where the advertised local port came from. Ordered most → least
24039
+ * authoritative, and the whole point of returning it: a client must be able to
24040
+ * tell a FACT about the hub's socket from an echo of its own guess.
24041
+ */
24042
+ var LocalPortSourceEnum = _enum([
24043
+ "server-config",
24044
+ "server-env",
24045
+ "caller-hint",
24046
+ "default"
24047
+ ]);
24048
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24049
+ var AdvertisedLocalPortSchema = object({
24050
+ port: number().int().min(1).max(65535),
24051
+ source: LocalPortSourceEnum
24052
+ });
24053
+ var GetConnectionEndpointsResultSchema = object({
24054
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24055
+ /**
24056
+ * The port the hub built the LAN/loopback URLs with, and where that number
24057
+ * came from.
24058
+ *
24059
+ * Returned rather than merely applied, because "the URL is right" and "the
24060
+ * client can KNOW the URL is right" are different properties. A client that
24061
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24062
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24063
+ * race the candidate or discard it. With `source` it can: anything but
24064
+ * `caller-hint` is the hub's own socket.
24065
+ *
24066
+ * Absent on hubs predating this field — a client that finds it missing is
24067
+ * talking to an echoing hub and must degrade exactly as it does for
24068
+ * `caller-hint`.
24069
+ */
24070
+ localPort: AdvertisedLocalPortSchema
24071
+ });
24007
24072
  /**
24008
24073
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24009
24074
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24024,8 +24089,13 @@ var AllowedAddressesSchema = object({
24024
24089
  */
24025
24090
  addresses: array(string()).readonly() });
24026
24091
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24027
- /** Local hub HTTP port to use in base URLs. */
24028
- port: number().int().min(1).max(65535),
24092
+ /**
24093
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24094
+ * written against the echoing contract keep working; the hub uses it
24095
+ * only when it cannot read its own port, and says so via
24096
+ * `localPort.source === 'caller-hint'`.
24097
+ */
24098
+ port: number().int().min(1).max(65535).optional(),
24029
24099
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24030
24100
  * candidate. Default `true`. */
24031
24101
  includeLoopback: boolean().optional(),
@@ -30184,11 +30254,51 @@ function startReachabilityPoll(options) {
30184
30254
  } };
30185
30255
  }
30186
30256
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30257
+ /**
30258
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30259
+ *
30260
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30261
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30262
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30263
+ * microphone on) change when an operator changes them, through a `setMask` /
30264
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30265
+ * A write is therefore its own invalidation: the window never delays an
30266
+ * operator's own change, however long it is.
30267
+ *
30268
+ * What the window really bounds is the ONE case we do not write: somebody
30269
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30270
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30271
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30272
+ * minute instead of 696
30273
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30274
+ *
30275
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30276
+ * On the awaiting default a window this long does not remove the stall, it
30277
+ * merely makes it rarer and just as long.
30278
+ */
30279
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30187
30280
  function createRuntimeStateBridge(params) {
30188
30281
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30189
30282
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30283
+ const staleRead = params.staleRead ?? "await-refresh";
30190
30284
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30191
30285
  let missCooldownUntil = 0;
30286
+ /**
30287
+ * The refresh this bridge currently has in the air, if any.
30288
+ *
30289
+ * Providers single-flight their own camera client, so this is not what stops
30290
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30291
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30292
+ * answered from the slice at once — even a cold, empty slice, which reports
30293
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30294
+ *
30295
+ * That distinction is the whole cost of an unreachable camera. Measured on
30296
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30297
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30298
+ * joined the wait and spent the caller's full 1.2s source budget — five
30299
+ * stalled polls per cooldown cycle, for one camera, forever.
30300
+ */
30301
+ let refreshInFlight = null;
30192
30302
  const readFetchedAt = () => {
30193
30303
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30194
30304
  return typeof value === "number" ? value : 0;
@@ -30209,14 +30319,14 @@ function createRuntimeStateBridge(params) {
30209
30319
  }
30210
30320
  });
30211
30321
  };
30212
- const ensureFresh = async () => {
30213
- const slice = runtimeState.getCapState(cap.name);
30214
- const fetchedAt = readFetchedAt();
30215
- if (slice && Date.now() - fetchedAt <= staleMs) {
30216
- missCooldownUntil = 0;
30217
- return;
30218
- }
30219
- if (Date.now() < missCooldownUntil) return;
30322
+ /**
30323
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30324
+ *
30325
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30326
+ * evidence the bridge has that the refresh persisted
30327
+ * anything, since providers swallow their own camera errors.
30328
+ */
30329
+ const runRefresh = async (fetchedAt) => {
30220
30330
  try {
30221
30331
  await refresh();
30222
30332
  } catch (err) {
@@ -30232,6 +30342,41 @@ function createRuntimeStateBridge(params) {
30232
30342
  }
30233
30343
  openMissCooldown(void 0);
30234
30344
  };
30345
+ /**
30346
+ * Start a refresh and remember it, at most one at a time.
30347
+ *
30348
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30349
+ * from the slice either way, so a floating rejection would take the process
30350
+ * down for a fault the miss cooldown has already recorded and logged.
30351
+ */
30352
+ const startRefresh = (fetchedAt) => {
30353
+ const existing = refreshInFlight;
30354
+ if (existing !== null) return existing;
30355
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30356
+ refreshInFlight = null;
30357
+ });
30358
+ refreshInFlight = started;
30359
+ return started;
30360
+ };
30361
+ const ensureFresh = async () => {
30362
+ const slice = runtimeState.getCapState(cap.name);
30363
+ const fetchedAt = readFetchedAt();
30364
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30365
+ missCooldownUntil = 0;
30366
+ return;
30367
+ }
30368
+ if (Date.now() < missCooldownUntil) return;
30369
+ if (staleRead === "serve-and-revalidate") {
30370
+ if (refreshInFlight !== null) return;
30371
+ if (slice && fetchedAt > 0) {
30372
+ startRefresh(fetchedAt);
30373
+ return;
30374
+ }
30375
+ await startRefresh(fetchedAt);
30376
+ return;
30377
+ }
30378
+ await runRefresh(fetchedAt);
30379
+ };
30235
30380
  const projectStatus = () => {
30236
30381
  const slice = runtimeState.getCapState(cap.name);
30237
30382
  if (!slice) return empty();
@@ -39456,7 +39601,6 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
39456
39601
  static PRIVACY_COORD_MAX = 8191;
39457
39602
  registerPrivacyMaskCap() {
39458
39603
  const CAP_NAME = "privacy-mask";
39459
- const STALE_MS = 1e4;
39460
39604
  const maxRegions = AmcrestCamera.PRIVACY_MASK_MAX_REGIONS;
39461
39605
  const coordMax = AmcrestCamera.PRIVACY_COORD_MAX;
39462
39606
  const refreshFromCamera = async () => {
@@ -39511,7 +39655,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
39511
39655
  cap: privacyMaskCapability,
39512
39656
  ownDeviceId: this.id,
39513
39657
  refresh: refreshFromCamera,
39514
- staleMs: STALE_MS,
39658
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
39659
+ staleRead: "serve-and-revalidate",
39515
39660
  logger: this.ctx.logger,
39516
39661
  empty: () => ({
39517
39662
  enabled: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-amcrest",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "Amcrest/Dahua camera device provider addon for CamStack — Dahua CGI over HTTP(S) with digest auth (snapshot, RTSP catalog, PTZ, image/day-night config)",
5
5
  "keywords": [
6
6
  "camstack",