@camstack/addon-provider-reolink 1.2.45 → 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.
Files changed (3) hide show
  1. package/dist/addon.js +346 -74
  2. package/dist/addon.mjs +346 -74
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -7605,7 +7605,7 @@ var RecordingBandSchema = object({
7605
7605
  postBufferSec: number().min(0).optional()
7606
7606
  });
7607
7607
  ({
7608
- preBufferSec: 10,
7608
+ preBufferSec: 15,
7609
7609
  postBufferSec: 30
7610
7610
  }).postBufferSec * 1e3;
7611
7611
  /**
@@ -13995,128 +13995,103 @@ var COCO_TO_MACRO = {
13995
13995
  var AUDIO_MACRO_LABELS = [
13996
13996
  {
13997
13997
  id: "speech",
13998
- name: "Speech",
13999
- icon: "🗣️"
13998
+ name: "Speech"
14000
13999
  },
14001
14000
  {
14002
14001
  id: "scream",
14003
- name: "Scream / Shout",
14004
- icon: "😱"
14002
+ name: "Scream / Shout"
14005
14003
  },
14006
14004
  {
14007
14005
  id: "crying",
14008
- name: "Crying / Baby",
14009
- icon: "😢"
14006
+ name: "Crying / Baby"
14010
14007
  },
14011
14008
  {
14012
14009
  id: "laughter",
14013
- name: "Laughter",
14014
- icon: "😂"
14010
+ name: "Laughter"
14015
14011
  },
14016
14012
  {
14017
14013
  id: "music",
14018
- name: "Music",
14019
- icon: "🎵"
14014
+ name: "Music"
14020
14015
  },
14021
14016
  {
14022
14017
  id: "dog",
14023
- name: "Dog",
14024
- icon: "🐕"
14018
+ name: "Dog"
14025
14019
  },
14026
14020
  {
14027
14021
  id: "cat",
14028
- name: "Cat",
14029
- icon: "🐈"
14022
+ name: "Cat"
14030
14023
  },
14031
14024
  {
14032
14025
  id: "bird",
14033
- name: "Bird",
14034
- icon: "🐦"
14026
+ name: "Bird"
14035
14027
  },
14036
14028
  {
14037
14029
  id: "animal",
14038
- name: "Animal (other)",
14039
- icon: "🐾"
14030
+ name: "Animal (other)"
14040
14031
  },
14041
14032
  {
14042
14033
  id: "alarm",
14043
- name: "Alarm / Siren",
14044
- icon: "🚨"
14034
+ name: "Alarm / Siren"
14045
14035
  },
14046
14036
  {
14047
14037
  id: "doorbell",
14048
- name: "Doorbell / Knock",
14049
- icon: "🔔"
14038
+ name: "Doorbell / Knock"
14050
14039
  },
14051
14040
  {
14052
14041
  id: "glass_breaking",
14053
- name: "Glass Breaking",
14054
- icon: "💥"
14042
+ name: "Glass Breaking"
14055
14043
  },
14056
14044
  {
14057
14045
  id: "gunshot",
14058
- name: "Gunshot / Explosion",
14059
- icon: "💣"
14046
+ name: "Gunshot / Explosion"
14060
14047
  },
14061
14048
  {
14062
14049
  id: "vehicle",
14063
- name: "Vehicle",
14064
- icon: "🚗"
14050
+ name: "Vehicle"
14065
14051
  },
14066
14052
  {
14067
14053
  id: "siren",
14068
- name: "Emergency Siren",
14069
- icon: "🚑"
14054
+ name: "Emergency Siren"
14070
14055
  },
14071
14056
  {
14072
14057
  id: "fire",
14073
- name: "Fire / Smoke",
14074
- icon: "🔥"
14058
+ name: "Fire / Smoke"
14075
14059
  },
14076
14060
  {
14077
14061
  id: "water",
14078
- name: "Water",
14079
- icon: "💧"
14062
+ name: "Water"
14080
14063
  },
14081
14064
  {
14082
14065
  id: "wind",
14083
- name: "Wind / Weather",
14084
- icon: "🌬️"
14066
+ name: "Wind / Weather"
14085
14067
  },
14086
14068
  {
14087
14069
  id: "door",
14088
- name: "Door",
14089
- icon: "🚪"
14070
+ name: "Door"
14090
14071
  },
14091
14072
  {
14092
14073
  id: "footsteps",
14093
- name: "Footsteps",
14094
- icon: "👣"
14074
+ name: "Footsteps"
14095
14075
  },
14096
14076
  {
14097
14077
  id: "crowd",
14098
- name: "Crowd / Chatter",
14099
- icon: "👥"
14078
+ name: "Crowd / Chatter"
14100
14079
  },
14101
14080
  {
14102
14081
  id: "telephone",
14103
- name: "Telephone",
14104
- icon: "📞"
14082
+ name: "Telephone"
14105
14083
  },
14106
14084
  {
14107
14085
  id: "engine",
14108
- name: "Engine / Motor",
14109
- icon: "⚙️"
14086
+ name: "Engine / Motor"
14110
14087
  },
14111
14088
  {
14112
14089
  id: "tools",
14113
- name: "Tools / Construction",
14114
- icon: "🔨"
14090
+ name: "Tools / Construction"
14115
14091
  },
14116
14092
  {
14117
14093
  id: "silence",
14118
- name: "Silence",
14119
- icon: "🤫"
14094
+ name: "Silence"
14120
14095
  }
14121
14096
  ];
14122
14097
  var YAMNET_TO_MACRO = {
@@ -16618,7 +16593,7 @@ var TrackEnvelopeSchema = object({
16618
16593
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16619
16594
  * keeps every scalar the list surfaces actually render (ids, class(es),
16620
16595
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16621
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
16596
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
16622
16597
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16623
16598
  * `getTrack`. Mirrors the event-store `projection` convention
16624
16599
  * (`getObjectEvents` et al.).
@@ -16913,6 +16888,35 @@ var TrackSchema = object({
16913
16888
  */
16914
16889
  hasFace: boolean().optional(),
16915
16890
  /**
16891
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
16892
+ * face an operator could ASSIGN to an identity.
16893
+ *
16894
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
16895
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
16896
+ * branch, before every gate, and means no more than "a face detector produced
16897
+ * a face detail". This one is stamped at the single moment the gallery row
16898
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
16899
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
16900
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
16901
+ * the crop-store drop. Everything between the detector and that insert can
16902
+ * legitimately refuse the face, so a flag written any earlier promises the
16903
+ * operator something to assign and delivers nothing.
16904
+ *
16905
+ * **Independent of recognition.** A face collected but never auto-matched is
16906
+ * still assignable — it is in fact the face an operator most wants to reach —
16907
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
16908
+ * `subLabel`; this says only that the raw material exists.
16909
+ *
16910
+ * **Set once, never cleared.** A track that produced a gallery row produced
16911
+ * one; deleting the row later is the gallery's business, not this flag's.
16912
+ *
16913
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
16914
+ * before the column omits it, and so does every server that predates the
16915
+ * field. A consumer must test `=== true` and render nothing otherwise —
16916
+ * never infer "no assignable face".
16917
+ */
16918
+ hasEmbeddedFace: boolean().optional(),
16919
+ /**
16916
16920
  * This subject CONTAINS a folded rider — a person the rider-pairing step
16917
16921
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
16918
16922
  * so the passage is tracked once and as a VEHICLE.
@@ -18302,6 +18306,32 @@ var DetailResultSchema = object({
18302
18306
  /** FRAME-space bbox (already mapped back from crop space). */
18303
18307
  bbox: NativeCropBboxSchema.optional(),
18304
18308
  embedding: string().optional(),
18309
+ /**
18310
+ * L2 magnitude of the RAW pre-normalization embedding. Additive and optional
18311
+ * so an older runner that does not send it degrades to "unmeasurable", which
18312
+ * the consuming gate treats as accept — never as reject.
18313
+ */
18314
+ embeddingMagnitude: number().optional(),
18315
+ /**
18316
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
18317
+ * `label`, and for the same reason: the producing node is the only side that
18318
+ * knows which model actually ran, and re-deriving it downstream is exactly
18319
+ * the inference this model exists to forbid.
18320
+ *
18321
+ * Additive and optional, so a runner that predates it degrades to
18322
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
18323
+ * its previous assumption instead of refusing.
18324
+ *
18325
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
18326
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
18327
+ * with nothing on the wire it took the id from the GALLERY, so the gate
18328
+ * compared the gallery against itself and could never fire. That turned the
18329
+ * one guard protecting the face feature space into a tautology, and it is
18330
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
18331
+ * gallery as the catalog comment predicts — it would have scored the new
18332
+ * model's vectors against the old model's samples in one cosine space.
18333
+ */
18334
+ embeddingModelId: string().optional(),
18305
18335
  label: string().optional(),
18306
18336
  /**
18307
18337
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -19177,6 +19207,12 @@ var CameraStatusSchema = object({
19177
19207
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19178
19208
  fetchedAt: number()
19179
19209
  });
19210
+ var InferenceDeviceExclusionReasonSchema = _enum([
19211
+ "disabled",
19212
+ "unavailable",
19213
+ "cannot-host-camera-root",
19214
+ "accelerator-preferred"
19215
+ ]);
19180
19216
  var NodeInferenceDeviceSchema = object({
19181
19217
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19182
19218
  key: string(),
@@ -19207,7 +19243,17 @@ var NodeInferenceDeviceSchema = object({
19207
19243
  * available per format; this is the stored selection that becomes the
19208
19244
  * default for EVERY camera landing on this accelerator.
19209
19245
  */
19210
- 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()
19211
19257
  });
19212
19258
  var NodeInferenceDevicesSchema = object({
19213
19259
  nodeId: string(),
@@ -19766,6 +19812,7 @@ var snapshotCapability = {
19766
19812
  isBattery: boolean(),
19767
19813
  reason: _enum([
19768
19814
  "disabled",
19815
+ "offline",
19769
19816
  "sleeping",
19770
19817
  "unreachable",
19771
19818
  "waking"
@@ -19855,9 +19902,17 @@ targets: array(object({
19855
19902
  sleeping: boolean(),
19856
19903
  /** Current device state rendered over the cached frame. State images
19857
19904
  * remain authoritative even when their photographic background is
19858
- * old; null means the link must carry a current camera frame. */
19905
+ * old; null means the link must carry a current camera frame.
19906
+ *
19907
+ * **This is the ONLY source a surface may derive an inactive tile
19908
+ * from.** A client that reads a device's `online`/`disabled` flags and
19909
+ * draws its own verdict will disagree with the pixels it is painting
19910
+ * — the frame comes from here, and so must the sentence over it
19911
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
19912
+ * state. */
19859
19913
  stateReason: _enum([
19860
19914
  "disabled",
19915
+ "offline",
19861
19916
  "sleeping",
19862
19917
  "unreachable",
19863
19918
  "waking"
@@ -24153,7 +24208,12 @@ var ListResultSchema = object({
24153
24208
  probedAt: number()
24154
24209
  });
24155
24210
  var PreferredSchema = LocalInterfaceSchema.nullable();
24156
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
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({
24157
24217
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24158
24218
  label: string(),
24159
24219
  /** Fully-formed base URL with scheme + host + port. */
@@ -24196,7 +24256,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24196
24256
  * ordering between polls.
24197
24257
  */
24198
24258
  priority: number()
24199
- })).readonly() });
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
+ });
24200
24295
  /**
24201
24296
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24202
24297
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24217,8 +24312,13 @@ var AllowedAddressesSchema = object({
24217
24312
  */
24218
24313
  addresses: array(string()).readonly() });
24219
24314
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24220
- /** Local hub HTTP port to use in base URLs. */
24221
- port: number().int().min(1).max(65535),
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(),
24222
24322
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24223
24323
  * candidate. Default `true`. */
24224
24324
  includeLoopback: boolean().optional(),
@@ -30325,11 +30425,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
30325
30425
  new Set(Object.values(DeviceType));
30326
30426
  DeviceFeature.BatteryOperated;
30327
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;
30328
30463
  function createRuntimeStateBridge(params) {
30329
30464
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30330
30465
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30466
+ const staleRead = params.staleRead ?? "await-refresh";
30331
30467
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30332
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;
30333
30485
  const readFetchedAt = () => {
30334
30486
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30335
30487
  return typeof value === "number" ? value : 0;
@@ -30350,14 +30502,14 @@ function createRuntimeStateBridge(params) {
30350
30502
  }
30351
30503
  });
30352
30504
  };
30353
- const ensureFresh = async () => {
30354
- const slice = runtimeState.getCapState(cap.name);
30355
- const fetchedAt = readFetchedAt();
30356
- if (slice && Date.now() - fetchedAt <= staleMs) {
30357
- missCooldownUntil = 0;
30358
- return;
30359
- }
30360
- if (Date.now() < missCooldownUntil) return;
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) => {
30361
30513
  try {
30362
30514
  await refresh();
30363
30515
  } catch (err) {
@@ -30373,6 +30525,41 @@ function createRuntimeStateBridge(params) {
30373
30525
  }
30374
30526
  openMissCooldown(void 0);
30375
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
+ };
30376
30563
  const projectStatus = () => {
30377
30564
  const slice = runtimeState.getCapState(cap.name);
30378
30565
  if (!slice) return empty();
@@ -37771,7 +37958,6 @@ var NC_AUDIO_DEFAULTS = {
37771
37958
  hitPercent: 60,
37772
37959
  samplingSeconds: 10
37773
37960
  };
37774
- new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
37775
37961
  new Set(["devices", "classes"]);
37776
37962
  NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
37777
37963
  /**
@@ -38005,6 +38191,39 @@ TimelapseRuleInputSchema.extend({
38005
38191
  createdAt: number(),
38006
38192
  updatedAt: number()
38007
38193
  });
38194
+ Object.freeze(Object.fromEntries([{
38195
+ stepId: "face-embedding",
38196
+ label: "Face recognition model",
38197
+ defaultModelId: "arcface-r100",
38198
+ indexName: "the enrolled face gallery",
38199
+ options: [
38200
+ {
38201
+ id: "arcface-r100",
38202
+ label: "ArcFace (ResNet34)"
38203
+ },
38204
+ {
38205
+ id: "auraface-r100",
38206
+ label: "AuraFace R100"
38207
+ },
38208
+ {
38209
+ id: "inception-resnet-v1",
38210
+ label: "Inception ResNet V1"
38211
+ }
38212
+ ]
38213
+ }, {
38214
+ stepId: "clip-embedding",
38215
+ label: "Semantic search model",
38216
+ defaultModelId: "mobileclip-s1",
38217
+ indexName: "the object semantic-search index",
38218
+ options: [{
38219
+ id: "mobileclip-s1",
38220
+ label: "MobileCLIP S1"
38221
+ }, {
38222
+ id: "mobileclip-s2",
38223
+ label: "MobileCLIP S2"
38224
+ }]
38225
+ }].map((s) => [s.stepId, s.defaultModelId])));
38226
+ string().min(1);
38008
38227
  object({
38009
38228
  /**
38010
38229
  * Fraction of the box's own size added on EACH side before cutting.
@@ -38025,10 +38244,21 @@ object({
38025
38244
  */
38026
38245
  square: boolean()
38027
38246
  });
38028
- ({
38247
+ /**
38248
+ * The convention in force when nobody has configured one — byte-for-byte the
38249
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
38250
+ */
38251
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
38029
38252
  paddingRatio: .15,
38030
38253
  square: false
38031
- }).paddingRatio;
38254
+ };
38255
+ Object.freeze({
38256
+ x: 0,
38257
+ y: 0,
38258
+ w: 1,
38259
+ h: 1
38260
+ });
38261
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
38032
38262
  /**
38033
38263
  * WHICH delivered frames the decode worker retains a native copy of.
38034
38264
  *
@@ -232313,6 +232543,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232313
232543
  * within a day while removing the per-poll round-trip entirely. */
232314
232544
  static CAP_OPTIONS_TTL_MS = 360 * 6e4;
232315
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
+ /**
232316
232574
  * Read a persisted `getOptions` descriptor, validated with the cap's
232317
232575
  * OWN Zod schema. Validation (not a cast) is what makes the
232318
232576
  * `z.unknown()` blob type-safe: an entry written by an older addon
@@ -232370,7 +232628,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232370
232628
  async resolveCapOptions(params) {
232371
232629
  const { capName, schema, probe, fallback } = params;
232372
232630
  const cached = this.readCapOptionsCache(capName, schema);
232373
- if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) return cached.value;
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
+ }
232374
232636
  if (this.isBattery && this.sleeping) {
232375
232637
  this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
232376
232638
  tags: { deviceId: this.id },
@@ -232381,19 +232643,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232381
232643
  });
232382
232644
  return cached?.value ?? fallback();
232383
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);
232384
232650
  try {
232385
- const value = await probe();
232651
+ const value = await started;
232386
232652
  await this.persistCapOptions(capName, value);
232653
+ this.capOptionsMissCooldownUntil.delete(capName);
232387
232654
  return value;
232388
232655
  } catch (err) {
232389
- this.ctx.logger.debug("cap options probe failed — serving cache/fallback", {
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", {
232390
232658
  tags: { deviceId: this.id },
232391
232659
  meta: {
232392
232660
  capName,
232661
+ cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
232662
+ hasCache: cached !== null,
232393
232663
  error: err instanceof Error ? err.message : String(err)
232394
232664
  }
232395
232665
  });
232396
232666
  return cached?.value ?? fallback();
232667
+ } finally {
232668
+ if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
232397
232669
  }
232398
232670
  }
232399
232671
  /**
@@ -232834,7 +233106,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232834
233106
  registerPrivacyMaskCap() {
232835
233107
  const channel = this.getChannel();
232836
233108
  const CAP_NAME = "privacy-mask";
232837
- const STALE_MS = 1e4;
232838
233109
  const refreshFromCamera = async () => {
232839
233110
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
232840
233111
  const promise = (async () => {
@@ -232887,7 +233158,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232887
233158
  cap: privacyMaskCapability,
232888
233159
  ownDeviceId: this.id,
232889
233160
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
232890
- staleMs: STALE_MS,
233161
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
233162
+ staleRead: "serve-and-revalidate",
232891
233163
  empty: () => ({
232892
233164
  enabled: false,
232893
233165
  regions: [],
package/dist/addon.mjs CHANGED
@@ -7600,7 +7600,7 @@ var RecordingBandSchema = object({
7600
7600
  postBufferSec: number().min(0).optional()
7601
7601
  });
7602
7602
  ({
7603
- preBufferSec: 10,
7603
+ preBufferSec: 15,
7604
7604
  postBufferSec: 30
7605
7605
  }).postBufferSec * 1e3;
7606
7606
  /**
@@ -13990,128 +13990,103 @@ var COCO_TO_MACRO = {
13990
13990
  var AUDIO_MACRO_LABELS = [
13991
13991
  {
13992
13992
  id: "speech",
13993
- name: "Speech",
13994
- icon: "🗣️"
13993
+ name: "Speech"
13995
13994
  },
13996
13995
  {
13997
13996
  id: "scream",
13998
- name: "Scream / Shout",
13999
- icon: "😱"
13997
+ name: "Scream / Shout"
14000
13998
  },
14001
13999
  {
14002
14000
  id: "crying",
14003
- name: "Crying / Baby",
14004
- icon: "😢"
14001
+ name: "Crying / Baby"
14005
14002
  },
14006
14003
  {
14007
14004
  id: "laughter",
14008
- name: "Laughter",
14009
- icon: "😂"
14005
+ name: "Laughter"
14010
14006
  },
14011
14007
  {
14012
14008
  id: "music",
14013
- name: "Music",
14014
- icon: "🎵"
14009
+ name: "Music"
14015
14010
  },
14016
14011
  {
14017
14012
  id: "dog",
14018
- name: "Dog",
14019
- icon: "🐕"
14013
+ name: "Dog"
14020
14014
  },
14021
14015
  {
14022
14016
  id: "cat",
14023
- name: "Cat",
14024
- icon: "🐈"
14017
+ name: "Cat"
14025
14018
  },
14026
14019
  {
14027
14020
  id: "bird",
14028
- name: "Bird",
14029
- icon: "🐦"
14021
+ name: "Bird"
14030
14022
  },
14031
14023
  {
14032
14024
  id: "animal",
14033
- name: "Animal (other)",
14034
- icon: "🐾"
14025
+ name: "Animal (other)"
14035
14026
  },
14036
14027
  {
14037
14028
  id: "alarm",
14038
- name: "Alarm / Siren",
14039
- icon: "🚨"
14029
+ name: "Alarm / Siren"
14040
14030
  },
14041
14031
  {
14042
14032
  id: "doorbell",
14043
- name: "Doorbell / Knock",
14044
- icon: "🔔"
14033
+ name: "Doorbell / Knock"
14045
14034
  },
14046
14035
  {
14047
14036
  id: "glass_breaking",
14048
- name: "Glass Breaking",
14049
- icon: "💥"
14037
+ name: "Glass Breaking"
14050
14038
  },
14051
14039
  {
14052
14040
  id: "gunshot",
14053
- name: "Gunshot / Explosion",
14054
- icon: "💣"
14041
+ name: "Gunshot / Explosion"
14055
14042
  },
14056
14043
  {
14057
14044
  id: "vehicle",
14058
- name: "Vehicle",
14059
- icon: "🚗"
14045
+ name: "Vehicle"
14060
14046
  },
14061
14047
  {
14062
14048
  id: "siren",
14063
- name: "Emergency Siren",
14064
- icon: "🚑"
14049
+ name: "Emergency Siren"
14065
14050
  },
14066
14051
  {
14067
14052
  id: "fire",
14068
- name: "Fire / Smoke",
14069
- icon: "🔥"
14053
+ name: "Fire / Smoke"
14070
14054
  },
14071
14055
  {
14072
14056
  id: "water",
14073
- name: "Water",
14074
- icon: "💧"
14057
+ name: "Water"
14075
14058
  },
14076
14059
  {
14077
14060
  id: "wind",
14078
- name: "Wind / Weather",
14079
- icon: "🌬️"
14061
+ name: "Wind / Weather"
14080
14062
  },
14081
14063
  {
14082
14064
  id: "door",
14083
- name: "Door",
14084
- icon: "🚪"
14065
+ name: "Door"
14085
14066
  },
14086
14067
  {
14087
14068
  id: "footsteps",
14088
- name: "Footsteps",
14089
- icon: "👣"
14069
+ name: "Footsteps"
14090
14070
  },
14091
14071
  {
14092
14072
  id: "crowd",
14093
- name: "Crowd / Chatter",
14094
- icon: "👥"
14073
+ name: "Crowd / Chatter"
14095
14074
  },
14096
14075
  {
14097
14076
  id: "telephone",
14098
- name: "Telephone",
14099
- icon: "📞"
14077
+ name: "Telephone"
14100
14078
  },
14101
14079
  {
14102
14080
  id: "engine",
14103
- name: "Engine / Motor",
14104
- icon: "⚙️"
14081
+ name: "Engine / Motor"
14105
14082
  },
14106
14083
  {
14107
14084
  id: "tools",
14108
- name: "Tools / Construction",
14109
- icon: "🔨"
14085
+ name: "Tools / Construction"
14110
14086
  },
14111
14087
  {
14112
14088
  id: "silence",
14113
- name: "Silence",
14114
- icon: "🤫"
14089
+ name: "Silence"
14115
14090
  }
14116
14091
  ];
14117
14092
  var YAMNET_TO_MACRO = {
@@ -16613,7 +16588,7 @@ var TrackEnvelopeSchema = object({
16613
16588
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16614
16589
  * keeps every scalar the list surfaces actually render (ids, class(es),
16615
16590
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16616
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
16591
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
16617
16592
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16618
16593
  * `getTrack`. Mirrors the event-store `projection` convention
16619
16594
  * (`getObjectEvents` et al.).
@@ -16908,6 +16883,35 @@ var TrackSchema = object({
16908
16883
  */
16909
16884
  hasFace: boolean().optional(),
16910
16885
  /**
16886
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
16887
+ * face an operator could ASSIGN to an identity.
16888
+ *
16889
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
16890
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
16891
+ * branch, before every gate, and means no more than "a face detector produced
16892
+ * a face detail". This one is stamped at the single moment the gallery row
16893
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
16894
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
16895
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
16896
+ * the crop-store drop. Everything between the detector and that insert can
16897
+ * legitimately refuse the face, so a flag written any earlier promises the
16898
+ * operator something to assign and delivers nothing.
16899
+ *
16900
+ * **Independent of recognition.** A face collected but never auto-matched is
16901
+ * still assignable — it is in fact the face an operator most wants to reach —
16902
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
16903
+ * `subLabel`; this says only that the raw material exists.
16904
+ *
16905
+ * **Set once, never cleared.** A track that produced a gallery row produced
16906
+ * one; deleting the row later is the gallery's business, not this flag's.
16907
+ *
16908
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
16909
+ * before the column omits it, and so does every server that predates the
16910
+ * field. A consumer must test `=== true` and render nothing otherwise —
16911
+ * never infer "no assignable face".
16912
+ */
16913
+ hasEmbeddedFace: boolean().optional(),
16914
+ /**
16911
16915
  * This subject CONTAINS a folded rider — a person the rider-pairing step
16912
16916
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
16913
16917
  * so the passage is tracked once and as a VEHICLE.
@@ -18297,6 +18301,32 @@ var DetailResultSchema = object({
18297
18301
  /** FRAME-space bbox (already mapped back from crop space). */
18298
18302
  bbox: NativeCropBboxSchema.optional(),
18299
18303
  embedding: string().optional(),
18304
+ /**
18305
+ * L2 magnitude of the RAW pre-normalization embedding. Additive and optional
18306
+ * so an older runner that does not send it degrades to "unmeasurable", which
18307
+ * the consuming gate treats as accept — never as reject.
18308
+ */
18309
+ embeddingMagnitude: number().optional(),
18310
+ /**
18311
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
18312
+ * `label`, and for the same reason: the producing node is the only side that
18313
+ * knows which model actually ran, and re-deriving it downstream is exactly
18314
+ * the inference this model exists to forbid.
18315
+ *
18316
+ * Additive and optional, so a runner that predates it degrades to
18317
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
18318
+ * its previous assumption instead of refusing.
18319
+ *
18320
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
18321
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
18322
+ * with nothing on the wire it took the id from the GALLERY, so the gate
18323
+ * compared the gallery against itself and could never fire. That turned the
18324
+ * one guard protecting the face feature space into a tautology, and it is
18325
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
18326
+ * gallery as the catalog comment predicts — it would have scored the new
18327
+ * model's vectors against the old model's samples in one cosine space.
18328
+ */
18329
+ embeddingModelId: string().optional(),
18300
18330
  label: string().optional(),
18301
18331
  /**
18302
18332
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -19172,6 +19202,12 @@ var CameraStatusSchema = object({
19172
19202
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19173
19203
  fetchedAt: number()
19174
19204
  });
19205
+ var InferenceDeviceExclusionReasonSchema = _enum([
19206
+ "disabled",
19207
+ "unavailable",
19208
+ "cannot-host-camera-root",
19209
+ "accelerator-preferred"
19210
+ ]);
19175
19211
  var NodeInferenceDeviceSchema = object({
19176
19212
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19177
19213
  key: string(),
@@ -19202,7 +19238,17 @@ var NodeInferenceDeviceSchema = object({
19202
19238
  * available per format; this is the stored selection that becomes the
19203
19239
  * default for EVERY camera landing on this accelerator.
19204
19240
  */
19205
- 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()
19206
19252
  });
19207
19253
  var NodeInferenceDevicesSchema = object({
19208
19254
  nodeId: string(),
@@ -19761,6 +19807,7 @@ var snapshotCapability = {
19761
19807
  isBattery: boolean(),
19762
19808
  reason: _enum([
19763
19809
  "disabled",
19810
+ "offline",
19764
19811
  "sleeping",
19765
19812
  "unreachable",
19766
19813
  "waking"
@@ -19850,9 +19897,17 @@ targets: array(object({
19850
19897
  sleeping: boolean(),
19851
19898
  /** Current device state rendered over the cached frame. State images
19852
19899
  * remain authoritative even when their photographic background is
19853
- * old; null means the link must carry a current camera frame. */
19900
+ * old; null means the link must carry a current camera frame.
19901
+ *
19902
+ * **This is the ONLY source a surface may derive an inactive tile
19903
+ * from.** A client that reads a device's `online`/`disabled` flags and
19904
+ * draws its own verdict will disagree with the pixels it is painting
19905
+ * — the frame comes from here, and so must the sentence over it
19906
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
19907
+ * state. */
19854
19908
  stateReason: _enum([
19855
19909
  "disabled",
19910
+ "offline",
19856
19911
  "sleeping",
19857
19912
  "unreachable",
19858
19913
  "waking"
@@ -24148,7 +24203,12 @@ var ListResultSchema = object({
24148
24203
  probedAt: number()
24149
24204
  });
24150
24205
  var PreferredSchema = LocalInterfaceSchema.nullable();
24151
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
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({
24152
24212
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24153
24213
  label: string(),
24154
24214
  /** Fully-formed base URL with scheme + host + port. */
@@ -24191,7 +24251,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24191
24251
  * ordering between polls.
24192
24252
  */
24193
24253
  priority: number()
24194
- })).readonly() });
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
+ });
24195
24290
  /**
24196
24291
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24197
24292
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24212,8 +24307,13 @@ var AllowedAddressesSchema = object({
24212
24307
  */
24213
24308
  addresses: array(string()).readonly() });
24214
24309
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24215
- /** Local hub HTTP port to use in base URLs. */
24216
- port: number().int().min(1).max(65535),
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(),
24217
24317
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24218
24318
  * candidate. Default `true`. */
24219
24319
  includeLoopback: boolean().optional(),
@@ -30320,11 +30420,63 @@ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeate
30320
30420
  new Set(Object.values(DeviceType));
30321
30421
  DeviceFeature.BatteryOperated;
30322
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;
30323
30458
  function createRuntimeStateBridge(params) {
30324
30459
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30325
30460
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30461
+ const staleRead = params.staleRead ?? "await-refresh";
30326
30462
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30327
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;
30328
30480
  const readFetchedAt = () => {
30329
30481
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30330
30482
  return typeof value === "number" ? value : 0;
@@ -30345,14 +30497,14 @@ function createRuntimeStateBridge(params) {
30345
30497
  }
30346
30498
  });
30347
30499
  };
30348
- const ensureFresh = async () => {
30349
- const slice = runtimeState.getCapState(cap.name);
30350
- const fetchedAt = readFetchedAt();
30351
- if (slice && Date.now() - fetchedAt <= staleMs) {
30352
- missCooldownUntil = 0;
30353
- return;
30354
- }
30355
- if (Date.now() < missCooldownUntil) return;
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) => {
30356
30508
  try {
30357
30509
  await refresh();
30358
30510
  } catch (err) {
@@ -30368,6 +30520,41 @@ function createRuntimeStateBridge(params) {
30368
30520
  }
30369
30521
  openMissCooldown(void 0);
30370
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
+ };
30371
30558
  const projectStatus = () => {
30372
30559
  const slice = runtimeState.getCapState(cap.name);
30373
30560
  if (!slice) return empty();
@@ -37766,7 +37953,6 @@ var NC_AUDIO_DEFAULTS = {
37766
37953
  hitPercent: 60,
37767
37954
  samplingSeconds: 10
37768
37955
  };
37769
- new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
37770
37956
  new Set(["devices", "classes"]);
37771
37957
  NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
37772
37958
  /**
@@ -38000,6 +38186,39 @@ TimelapseRuleInputSchema.extend({
38000
38186
  createdAt: number(),
38001
38187
  updatedAt: number()
38002
38188
  });
38189
+ Object.freeze(Object.fromEntries([{
38190
+ stepId: "face-embedding",
38191
+ label: "Face recognition model",
38192
+ defaultModelId: "arcface-r100",
38193
+ indexName: "the enrolled face gallery",
38194
+ options: [
38195
+ {
38196
+ id: "arcface-r100",
38197
+ label: "ArcFace (ResNet34)"
38198
+ },
38199
+ {
38200
+ id: "auraface-r100",
38201
+ label: "AuraFace R100"
38202
+ },
38203
+ {
38204
+ id: "inception-resnet-v1",
38205
+ label: "Inception ResNet V1"
38206
+ }
38207
+ ]
38208
+ }, {
38209
+ stepId: "clip-embedding",
38210
+ label: "Semantic search model",
38211
+ defaultModelId: "mobileclip-s1",
38212
+ indexName: "the object semantic-search index",
38213
+ options: [{
38214
+ id: "mobileclip-s1",
38215
+ label: "MobileCLIP S1"
38216
+ }, {
38217
+ id: "mobileclip-s2",
38218
+ label: "MobileCLIP S2"
38219
+ }]
38220
+ }].map((s) => [s.stepId, s.defaultModelId])));
38221
+ string().min(1);
38003
38222
  object({
38004
38223
  /**
38005
38224
  * Fraction of the box's own size added on EACH side before cutting.
@@ -38020,10 +38239,21 @@ object({
38020
38239
  */
38021
38240
  square: boolean()
38022
38241
  });
38023
- ({
38242
+ /**
38243
+ * The convention in force when nobody has configured one — byte-for-byte the
38244
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
38245
+ */
38246
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
38024
38247
  paddingRatio: .15,
38025
38248
  square: false
38026
- }).paddingRatio;
38249
+ };
38250
+ Object.freeze({
38251
+ x: 0,
38252
+ y: 0,
38253
+ w: 1,
38254
+ h: 1
38255
+ });
38256
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
38027
38257
  /**
38028
38258
  * WHICH delivered frames the decode worker retains a native copy of.
38029
38259
  *
@@ -232293,6 +232523,34 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232293
232523
  * within a day while removing the per-poll round-trip entirely. */
232294
232524
  static CAP_OPTIONS_TTL_MS = 360 * 6e4;
232295
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
+ /**
232296
232554
  * Read a persisted `getOptions` descriptor, validated with the cap's
232297
232555
  * OWN Zod schema. Validation (not a cast) is what makes the
232298
232556
  * `z.unknown()` blob type-safe: an entry written by an older addon
@@ -232350,7 +232608,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232350
232608
  async resolveCapOptions(params) {
232351
232609
  const { capName, schema, probe, fallback } = params;
232352
232610
  const cached = this.readCapOptionsCache(capName, schema);
232353
- if (cached && Date.now() - cached.fetchedAt < ReolinkCamera.CAP_OPTIONS_TTL_MS) return cached.value;
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
+ }
232354
232616
  if (this.isBattery && this.sleeping) {
232355
232617
  this.ctx.logger.debug("cap options: battery cam sleeping — serving cache, not probing", {
232356
232618
  tags: { deviceId: this.id },
@@ -232361,19 +232623,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232361
232623
  });
232362
232624
  return cached?.value ?? fallback();
232363
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);
232364
232630
  try {
232365
- const value = await probe();
232631
+ const value = await started;
232366
232632
  await this.persistCapOptions(capName, value);
232633
+ this.capOptionsMissCooldownUntil.delete(capName);
232367
232634
  return value;
232368
232635
  } catch (err) {
232369
- this.ctx.logger.debug("cap options probe failed — serving cache/fallback", {
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", {
232370
232638
  tags: { deviceId: this.id },
232371
232639
  meta: {
232372
232640
  capName,
232641
+ cooldownMs: RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS,
232642
+ hasCache: cached !== null,
232373
232643
  error: err instanceof Error ? err.message : String(err)
232374
232644
  }
232375
232645
  });
232376
232646
  return cached?.value ?? fallback();
232647
+ } finally {
232648
+ if (this.capOptionsInFlight.get(capName) === started) this.capOptionsInFlight.delete(capName);
232377
232649
  }
232378
232650
  }
232379
232651
  /**
@@ -232814,7 +233086,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232814
233086
  registerPrivacyMaskCap() {
232815
233087
  const channel = this.getChannel();
232816
233088
  const CAP_NAME = "privacy-mask";
232817
- const STALE_MS = 1e4;
232818
233089
  const refreshFromCamera = async () => {
232819
233090
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
232820
233091
  const promise = (async () => {
@@ -232867,7 +233138,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
232867
233138
  cap: privacyMaskCapability,
232868
233139
  ownDeviceId: this.id,
232869
233140
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
232870
- staleMs: STALE_MS,
233141
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
233142
+ staleRead: "serve-and-revalidate",
232871
233143
  empty: () => ({
232872
233144
  enabled: false,
232873
233145
  regions: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.45",
3
+ "version": "1.2.47",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",