@camstack/addon-provider-hikvision 1.2.30 → 1.2.32

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 +420 -95
  2. package/dist/addon.mjs +420 -95
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -7561,7 +7561,7 @@ var RecordingBandSchema = object({
7561
7561
  postBufferSec: number().min(0).optional()
7562
7562
  });
7563
7563
  ({
7564
- preBufferSec: 10,
7564
+ preBufferSec: 15,
7565
7565
  postBufferSec: 30
7566
7566
  }).postBufferSec * 1e3;
7567
7567
  /**
@@ -13951,128 +13951,103 @@ var COCO_TO_MACRO = {
13951
13951
  var AUDIO_MACRO_LABELS = [
13952
13952
  {
13953
13953
  id: "speech",
13954
- name: "Speech",
13955
- icon: "🗣️"
13954
+ name: "Speech"
13956
13955
  },
13957
13956
  {
13958
13957
  id: "scream",
13959
- name: "Scream / Shout",
13960
- icon: "😱"
13958
+ name: "Scream / Shout"
13961
13959
  },
13962
13960
  {
13963
13961
  id: "crying",
13964
- name: "Crying / Baby",
13965
- icon: "😢"
13962
+ name: "Crying / Baby"
13966
13963
  },
13967
13964
  {
13968
13965
  id: "laughter",
13969
- name: "Laughter",
13970
- icon: "😂"
13966
+ name: "Laughter"
13971
13967
  },
13972
13968
  {
13973
13969
  id: "music",
13974
- name: "Music",
13975
- icon: "🎵"
13970
+ name: "Music"
13976
13971
  },
13977
13972
  {
13978
13973
  id: "dog",
13979
- name: "Dog",
13980
- icon: "🐕"
13974
+ name: "Dog"
13981
13975
  },
13982
13976
  {
13983
13977
  id: "cat",
13984
- name: "Cat",
13985
- icon: "🐈"
13978
+ name: "Cat"
13986
13979
  },
13987
13980
  {
13988
13981
  id: "bird",
13989
- name: "Bird",
13990
- icon: "🐦"
13982
+ name: "Bird"
13991
13983
  },
13992
13984
  {
13993
13985
  id: "animal",
13994
- name: "Animal (other)",
13995
- icon: "🐾"
13986
+ name: "Animal (other)"
13996
13987
  },
13997
13988
  {
13998
13989
  id: "alarm",
13999
- name: "Alarm / Siren",
14000
- icon: "🚨"
13990
+ name: "Alarm / Siren"
14001
13991
  },
14002
13992
  {
14003
13993
  id: "doorbell",
14004
- name: "Doorbell / Knock",
14005
- icon: "🔔"
13994
+ name: "Doorbell / Knock"
14006
13995
  },
14007
13996
  {
14008
13997
  id: "glass_breaking",
14009
- name: "Glass Breaking",
14010
- icon: "💥"
13998
+ name: "Glass Breaking"
14011
13999
  },
14012
14000
  {
14013
14001
  id: "gunshot",
14014
- name: "Gunshot / Explosion",
14015
- icon: "💣"
14002
+ name: "Gunshot / Explosion"
14016
14003
  },
14017
14004
  {
14018
14005
  id: "vehicle",
14019
- name: "Vehicle",
14020
- icon: "🚗"
14006
+ name: "Vehicle"
14021
14007
  },
14022
14008
  {
14023
14009
  id: "siren",
14024
- name: "Emergency Siren",
14025
- icon: "🚑"
14010
+ name: "Emergency Siren"
14026
14011
  },
14027
14012
  {
14028
14013
  id: "fire",
14029
- name: "Fire / Smoke",
14030
- icon: "🔥"
14014
+ name: "Fire / Smoke"
14031
14015
  },
14032
14016
  {
14033
14017
  id: "water",
14034
- name: "Water",
14035
- icon: "💧"
14018
+ name: "Water"
14036
14019
  },
14037
14020
  {
14038
14021
  id: "wind",
14039
- name: "Wind / Weather",
14040
- icon: "🌬️"
14022
+ name: "Wind / Weather"
14041
14023
  },
14042
14024
  {
14043
14025
  id: "door",
14044
- name: "Door",
14045
- icon: "🚪"
14026
+ name: "Door"
14046
14027
  },
14047
14028
  {
14048
14029
  id: "footsteps",
14049
- name: "Footsteps",
14050
- icon: "👣"
14030
+ name: "Footsteps"
14051
14031
  },
14052
14032
  {
14053
14033
  id: "crowd",
14054
- name: "Crowd / Chatter",
14055
- icon: "👥"
14034
+ name: "Crowd / Chatter"
14056
14035
  },
14057
14036
  {
14058
14037
  id: "telephone",
14059
- name: "Telephone",
14060
- icon: "📞"
14038
+ name: "Telephone"
14061
14039
  },
14062
14040
  {
14063
14041
  id: "engine",
14064
- name: "Engine / Motor",
14065
- icon: "⚙️"
14042
+ name: "Engine / Motor"
14066
14043
  },
14067
14044
  {
14068
14045
  id: "tools",
14069
- name: "Tools / Construction",
14070
- icon: "🔨"
14046
+ name: "Tools / Construction"
14071
14047
  },
14072
14048
  {
14073
14049
  id: "silence",
14074
- name: "Silence",
14075
- icon: "🤫"
14050
+ name: "Silence"
14076
14051
  }
14077
14052
  ];
14078
14053
  var YAMNET_TO_MACRO = {
@@ -16574,7 +16549,7 @@ var TrackEnvelopeSchema = object({
16574
16549
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16575
16550
  * keeps every scalar the list surfaces actually render (ids, class(es),
16576
16551
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16577
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
16552
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
16578
16553
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16579
16554
  * `getTrack`. Mirrors the event-store `projection` convention
16580
16555
  * (`getObjectEvents` et al.).
@@ -16869,6 +16844,35 @@ var TrackSchema = object({
16869
16844
  */
16870
16845
  hasFace: boolean().optional(),
16871
16846
  /**
16847
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
16848
+ * face an operator could ASSIGN to an identity.
16849
+ *
16850
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
16851
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
16852
+ * branch, before every gate, and means no more than "a face detector produced
16853
+ * a face detail". This one is stamped at the single moment the gallery row
16854
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
16855
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
16856
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
16857
+ * the crop-store drop. Everything between the detector and that insert can
16858
+ * legitimately refuse the face, so a flag written any earlier promises the
16859
+ * operator something to assign and delivers nothing.
16860
+ *
16861
+ * **Independent of recognition.** A face collected but never auto-matched is
16862
+ * still assignable — it is in fact the face an operator most wants to reach —
16863
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
16864
+ * `subLabel`; this says only that the raw material exists.
16865
+ *
16866
+ * **Set once, never cleared.** A track that produced a gallery row produced
16867
+ * one; deleting the row later is the gallery's business, not this flag's.
16868
+ *
16869
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
16870
+ * before the column omits it, and so does every server that predates the
16871
+ * field. A consumer must test `=== true` and render nothing otherwise —
16872
+ * never infer "no assignable face".
16873
+ */
16874
+ hasEmbeddedFace: boolean().optional(),
16875
+ /**
16872
16876
  * This subject CONTAINS a folded rider — a person the rider-pairing step
16873
16877
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
16874
16878
  * so the passage is tracked once and as a VEHICLE.
@@ -18258,6 +18262,32 @@ var DetailResultSchema = object({
18258
18262
  /** FRAME-space bbox (already mapped back from crop space). */
18259
18263
  bbox: NativeCropBboxSchema.optional(),
18260
18264
  embedding: string().optional(),
18265
+ /**
18266
+ * L2 magnitude of the RAW pre-normalization embedding. Additive and optional
18267
+ * so an older runner that does not send it degrades to "unmeasurable", which
18268
+ * the consuming gate treats as accept — never as reject.
18269
+ */
18270
+ embeddingMagnitude: number().optional(),
18271
+ /**
18272
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
18273
+ * `label`, and for the same reason: the producing node is the only side that
18274
+ * knows which model actually ran, and re-deriving it downstream is exactly
18275
+ * the inference this model exists to forbid.
18276
+ *
18277
+ * Additive and optional, so a runner that predates it degrades to
18278
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
18279
+ * its previous assumption instead of refusing.
18280
+ *
18281
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
18282
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
18283
+ * with nothing on the wire it took the id from the GALLERY, so the gate
18284
+ * compared the gallery against itself and could never fire. That turned the
18285
+ * one guard protecting the face feature space into a tautology, and it is
18286
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
18287
+ * gallery as the catalog comment predicts — it would have scored the new
18288
+ * model's vectors against the old model's samples in one cosine space.
18289
+ */
18290
+ embeddingModelId: string().optional(),
18261
18291
  label: string().optional(),
18262
18292
  /**
18263
18293
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -19133,6 +19163,12 @@ var CameraStatusSchema = object({
19133
19163
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
19134
19164
  fetchedAt: number()
19135
19165
  });
19166
+ var InferenceDeviceExclusionReasonSchema = _enum([
19167
+ "disabled",
19168
+ "unavailable",
19169
+ "cannot-host-camera-root",
19170
+ "accelerator-preferred"
19171
+ ]);
19136
19172
  var NodeInferenceDeviceSchema = object({
19137
19173
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
19138
19174
  key: string(),
@@ -19163,7 +19199,17 @@ var NodeInferenceDeviceSchema = object({
19163
19199
  * available per format; this is the stored selection that becomes the
19164
19200
  * default for EVERY camera landing on this accelerator.
19165
19201
  */
19166
- steps: record(string(), DeviceStepConfigSchema).optional()
19202
+ steps: record(string(), DeviceStepConfigSchema).optional(),
19203
+ /**
19204
+ * `null` when the device IS a camera-root candidate on this node; otherwise
19205
+ * the reason the dispatcher drops it. Computed by the SAME
19206
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
19207
+ * never disagree with the election — deriving it in the UI from
19208
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
19209
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
19210
+ * "an accelerator is serving" predicate).
19211
+ */
19212
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
19167
19213
  });
19168
19214
  var NodeInferenceDevicesSchema = object({
19169
19215
  nodeId: string(),
@@ -19722,6 +19768,7 @@ var snapshotCapability = {
19722
19768
  isBattery: boolean(),
19723
19769
  reason: _enum([
19724
19770
  "disabled",
19771
+ "offline",
19725
19772
  "sleeping",
19726
19773
  "unreachable",
19727
19774
  "waking"
@@ -19811,9 +19858,17 @@ targets: array(object({
19811
19858
  sleeping: boolean(),
19812
19859
  /** Current device state rendered over the cached frame. State images
19813
19860
  * remain authoritative even when their photographic background is
19814
- * old; null means the link must carry a current camera frame. */
19861
+ * old; null means the link must carry a current camera frame.
19862
+ *
19863
+ * **This is the ONLY source a surface may derive an inactive tile
19864
+ * from.** A client that reads a device's `online`/`disabled` flags and
19865
+ * draws its own verdict will disagree with the pixels it is painting
19866
+ * — the frame comes from here, and so must the sentence over it
19867
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
19868
+ * state. */
19815
19869
  stateReason: _enum([
19816
19870
  "disabled",
19871
+ "offline",
19817
19872
  "sleeping",
19818
19873
  "unreachable",
19819
19874
  "waking"
@@ -24109,7 +24164,12 @@ var ListResultSchema = object({
24109
24164
  probedAt: number()
24110
24165
  });
24111
24166
  var PreferredSchema = LocalInterfaceSchema.nullable();
24112
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24167
+ /**
24168
+ * Candidate base URL for the SDK to race on connect. Order matters —
24169
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24170
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24171
+ */
24172
+ var ConnectionEndpointSchema = object({
24113
24173
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24114
24174
  label: string(),
24115
24175
  /** Fully-formed base URL with scheme + host + port. */
@@ -24152,7 +24212,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24152
24212
  * ordering between polls.
24153
24213
  */
24154
24214
  priority: number()
24155
- })).readonly() });
24215
+ });
24216
+ /**
24217
+ * Where the advertised local port came from. Ordered most → least
24218
+ * authoritative, and the whole point of returning it: a client must be able to
24219
+ * tell a FACT about the hub's socket from an echo of its own guess.
24220
+ */
24221
+ var LocalPortSourceEnum = _enum([
24222
+ "server-config",
24223
+ "server-env",
24224
+ "caller-hint",
24225
+ "default"
24226
+ ]);
24227
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24228
+ var AdvertisedLocalPortSchema = object({
24229
+ port: number().int().min(1).max(65535),
24230
+ source: LocalPortSourceEnum
24231
+ });
24232
+ var GetConnectionEndpointsResultSchema = object({
24233
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24234
+ /**
24235
+ * The port the hub built the LAN/loopback URLs with, and where that number
24236
+ * came from.
24237
+ *
24238
+ * Returned rather than merely applied, because "the URL is right" and "the
24239
+ * client can KNOW the URL is right" are different properties. A client that
24240
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24241
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24242
+ * race the candidate or discard it. With `source` it can: anything but
24243
+ * `caller-hint` is the hub's own socket.
24244
+ *
24245
+ * Absent on hubs predating this field — a client that finds it missing is
24246
+ * talking to an echoing hub and must degrade exactly as it does for
24247
+ * `caller-hint`.
24248
+ */
24249
+ localPort: AdvertisedLocalPortSchema
24250
+ });
24156
24251
  /**
24157
24252
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24158
24253
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24173,8 +24268,13 @@ var AllowedAddressesSchema = object({
24173
24268
  */
24174
24269
  addresses: array(string()).readonly() });
24175
24270
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24176
- /** Local hub HTTP port to use in base URLs. */
24177
- port: number().int().min(1).max(65535),
24271
+ /**
24272
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24273
+ * written against the echoing contract keep working; the hub uses it
24274
+ * only when it cannot read its own port, and says so via
24275
+ * `localPort.source === 'caller-hint'`.
24276
+ */
24277
+ port: number().int().min(1).max(65535).optional(),
24178
24278
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24179
24279
  * candidate. Default `true`. */
24180
24280
  includeLoopback: boolean().optional(),
@@ -30393,11 +30493,63 @@ function startReachabilityPoll(options) {
30393
30493
  } };
30394
30494
  }
30395
30495
  var LAST_FETCHED_FIELD = "lastFetchedAt";
30496
+ /**
30497
+ * How long a bridge stops re-attempting a refresh that did not land.
30498
+ *
30499
+ * Sized against the failure it exists for: a camera whose control plane
30500
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
30501
+ * neighbour never answers, up to the client's own timeout otherwise) on
30502
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
30503
+ * without a cooldown the slice is permanently stale and EVERY read pays
30504
+ * that price. One attempt per minute is enough to notice the camera
30505
+ * coming back; per-read is enough to stall the viewer's first paint.
30506
+ */
30507
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
30508
+ /**
30509
+ * The freshness window for a cap whose value changes ONLY when WE write it.
30510
+ *
30511
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
30512
+ * battery level, a day/night state that flips at dusk. It is the wrong number
30513
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
30514
+ * microphone on) change when an operator changes them, through a `setMask` /
30515
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
30516
+ * A write is therefore its own invalidation: the window never delays an
30517
+ * operator's own change, however long it is.
30518
+ *
30519
+ * What the window really bounds is the ONE case we do not write: somebody
30520
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
30521
+ * the trade — that change is visible within ten minutes, and a fleet of 29
30522
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
30523
+ * minute instead of 696
30524
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
30525
+ *
30526
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
30527
+ * On the awaiting default a window this long does not remove the stall, it
30528
+ * merely makes it rarer and just as long.
30529
+ */
30530
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
30396
30531
  function createRuntimeStateBridge(params) {
30397
30532
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
30398
30533
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
30534
+ const staleRead = params.staleRead ?? "await-refresh";
30399
30535
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
30400
30536
  let missCooldownUntil = 0;
30537
+ /**
30538
+ * The refresh this bridge currently has in the air, if any.
30539
+ *
30540
+ * Providers single-flight their own camera client, so this is not what stops
30541
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
30542
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
30543
+ * answered from the slice at once — even a cold, empty slice, which reports
30544
+ * UNKNOWN, which is the truth about a camera nobody has reached.
30545
+ *
30546
+ * That distinction is the whole cost of an unreachable camera. Measured on
30547
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
30548
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
30549
+ * joined the wait and spent the caller's full 1.2s source budget — five
30550
+ * stalled polls per cooldown cycle, for one camera, forever.
30551
+ */
30552
+ let refreshInFlight = null;
30401
30553
  const readFetchedAt = () => {
30402
30554
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
30403
30555
  return typeof value === "number" ? value : 0;
@@ -30418,14 +30570,14 @@ function createRuntimeStateBridge(params) {
30418
30570
  }
30419
30571
  });
30420
30572
  };
30421
- const ensureFresh = async () => {
30422
- const slice = runtimeState.getCapState(cap.name);
30423
- const fetchedAt = readFetchedAt();
30424
- if (slice && Date.now() - fetchedAt <= staleMs) {
30425
- missCooldownUntil = 0;
30426
- return;
30427
- }
30428
- if (Date.now() < missCooldownUntil) return;
30573
+ /**
30574
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
30575
+ *
30576
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
30577
+ * evidence the bridge has that the refresh persisted
30578
+ * anything, since providers swallow their own camera errors.
30579
+ */
30580
+ const runRefresh = async (fetchedAt) => {
30429
30581
  try {
30430
30582
  await refresh();
30431
30583
  } catch (err) {
@@ -30441,6 +30593,41 @@ function createRuntimeStateBridge(params) {
30441
30593
  }
30442
30594
  openMissCooldown(void 0);
30443
30595
  };
30596
+ /**
30597
+ * Start a refresh and remember it, at most one at a time.
30598
+ *
30599
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
30600
+ * from the slice either way, so a floating rejection would take the process
30601
+ * down for a fault the miss cooldown has already recorded and logged.
30602
+ */
30603
+ const startRefresh = (fetchedAt) => {
30604
+ const existing = refreshInFlight;
30605
+ if (existing !== null) return existing;
30606
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
30607
+ refreshInFlight = null;
30608
+ });
30609
+ refreshInFlight = started;
30610
+ return started;
30611
+ };
30612
+ const ensureFresh = async () => {
30613
+ const slice = runtimeState.getCapState(cap.name);
30614
+ const fetchedAt = readFetchedAt();
30615
+ if (slice && Date.now() - fetchedAt <= staleMs) {
30616
+ missCooldownUntil = 0;
30617
+ return;
30618
+ }
30619
+ if (Date.now() < missCooldownUntil) return;
30620
+ if (staleRead === "serve-and-revalidate") {
30621
+ if (refreshInFlight !== null) return;
30622
+ if (slice && fetchedAt > 0) {
30623
+ startRefresh(fetchedAt);
30624
+ return;
30625
+ }
30626
+ await startRefresh(fetchedAt);
30627
+ return;
30628
+ }
30629
+ await runRefresh(fetchedAt);
30630
+ };
30444
30631
  const projectStatus = () => {
30445
30632
  const slice = runtimeState.getCapState(cap.name);
30446
30633
  if (!slice) return empty();
@@ -37839,7 +38026,6 @@ var NC_AUDIO_DEFAULTS = {
37839
38026
  hitPercent: 60,
37840
38027
  samplingSeconds: 10
37841
38028
  };
37842
- new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
37843
38029
  new Set(["devices", "classes"]);
37844
38030
  NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
37845
38031
  /**
@@ -38073,6 +38259,39 @@ TimelapseRuleInputSchema.extend({
38073
38259
  createdAt: number(),
38074
38260
  updatedAt: number()
38075
38261
  });
38262
+ Object.freeze(Object.fromEntries([{
38263
+ stepId: "face-embedding",
38264
+ label: "Face recognition model",
38265
+ defaultModelId: "arcface-r100",
38266
+ indexName: "the enrolled face gallery",
38267
+ options: [
38268
+ {
38269
+ id: "arcface-r100",
38270
+ label: "ArcFace (ResNet34)"
38271
+ },
38272
+ {
38273
+ id: "auraface-r100",
38274
+ label: "AuraFace R100"
38275
+ },
38276
+ {
38277
+ id: "inception-resnet-v1",
38278
+ label: "Inception ResNet V1"
38279
+ }
38280
+ ]
38281
+ }, {
38282
+ stepId: "clip-embedding",
38283
+ label: "Semantic search model",
38284
+ defaultModelId: "mobileclip-s1",
38285
+ indexName: "the object semantic-search index",
38286
+ options: [{
38287
+ id: "mobileclip-s1",
38288
+ label: "MobileCLIP S1"
38289
+ }, {
38290
+ id: "mobileclip-s2",
38291
+ label: "MobileCLIP S2"
38292
+ }]
38293
+ }].map((s) => [s.stepId, s.defaultModelId])));
38294
+ string().min(1);
38076
38295
  object({
38077
38296
  /**
38078
38297
  * Fraction of the box's own size added on EACH side before cutting.
@@ -38093,10 +38312,21 @@ object({
38093
38312
  */
38094
38313
  square: boolean()
38095
38314
  });
38096
- ({
38315
+ /**
38316
+ * The convention in force when nobody has configured one — byte-for-byte the
38317
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
38318
+ */
38319
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
38097
38320
  paddingRatio: .15,
38098
38321
  square: false
38099
- }).paddingRatio;
38322
+ };
38323
+ Object.freeze({
38324
+ x: 0,
38325
+ y: 0,
38326
+ w: 1,
38327
+ h: 1
38328
+ });
38329
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
38100
38330
  /**
38101
38331
  * WHICH delivered frames the decode worker retains a native copy of.
38102
38332
  *
@@ -42254,6 +42484,29 @@ var HikvisionDeviceCacheSchema = object({
42254
42484
  * `DeviceFeature.TwoWayAudio` + the `intercom` cap registration.
42255
42485
  */
42256
42486
  hasIntercom: boolean().optional(),
42487
+ /**
42488
+ * The `privacy-mask` cap's per-camera AVAILABILITY facts, as the last
42489
+ * successful read saw them.
42490
+ *
42491
+ * Persisted for the same reason Reolink persists its `capOptionsSnapshot`:
42492
+ * `privacyMask.getOptions` sits on a POLLED path (the admin Cameras badge
42493
+ * asks it per camera every 5s), and these describe what the model can do —
42494
+ * they change on a firmware update, not during operation. Reading them from
42495
+ * here costs no ISAPI round trip, and it survives an addon restart, which
42496
+ * an in-process value does not.
42497
+ *
42498
+ * They come out of the SAME two reads that build the cap's status slice, so
42499
+ * they cannot drift from it.
42500
+ */
42501
+ privacyMaskOptions: object({
42502
+ /** Zones the firmware advertises. */
42503
+ maxRegions: number().int().nonnegative(),
42504
+ /** True only when a streaming channel reported an audio flag we can
42505
+ * patch — a switch that writes nothing must never be offered. */
42506
+ supportsAudioMute: boolean(),
42507
+ /** Epoch ms of the read that produced this. */
42508
+ fetchedAt: number()
42509
+ }).optional(),
42257
42510
  /** ISAPI codec the firmware reports for the talk channel (e.g.
42258
42511
  * `G.711ulaw`, `G.711alaw`). Persisted so the intercom session
42259
42512
  * doesn't have to re-discover on every open. Optional — discovery
@@ -44256,7 +44509,68 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44256
44509
  registerPrivacyMaskCap(cameraNumber) {
44257
44510
  const isapi = this.ensureClient();
44258
44511
  const CAP_NAME = "privacy-mask";
44259
- const STALE_MS = 1e4;
44512
+ /**
44513
+ * The per-camera AVAILABILITY facts, as the last successful read saw them.
44514
+ *
44515
+ * `getOptions` used to make its own pair of ISAPI calls on every
44516
+ * invocation — and `getCameraStatuses` calls it once per camera per 5s
44517
+ * poll, so on this fleet (20 of 29 cameras are Hikvision) it was the
44518
+ * single largest source of camera traffic on the badge path.
44519
+ *
44520
+ * It is not a second copy of anything: `maxRegions` and the audio-channel
44521
+ * census live nowhere else, and they come out of the SAME two reads that
44522
+ * build the status slice, so they cannot disagree with it. They are kept in
44523
+ * the persisted `deviceCache` rather than only in memory because they must
44524
+ * survive an addon restart — the status slice does (`durability:
44525
+ * 'restored'`), and an options read that did not would be the one caller
44526
+ * still dialling every camera after every deploy.
44527
+ */
44528
+ const readPersistedOptions = () => {
44529
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44530
+ if (!stored) return null;
44531
+ return {
44532
+ maxRegions: stored.maxRegions,
44533
+ supportedShapes: ["rect", "polygon"],
44534
+ polygonVertices: {
44535
+ min: 4,
44536
+ max: 4
44537
+ },
44538
+ supportsAudioMute: stored.supportsAudioMute
44539
+ };
44540
+ };
44541
+ /**
44542
+ * Epoch ms until which a COLD options probe is not re-attempted.
44543
+ *
44544
+ * Only the cold path below can dial, and only on a camera that has never
44545
+ * produced a descriptor — but "never" and "unreachable" are the same state,
44546
+ * so without this an unreachable camera would be dialled once per poll
44547
+ * forever. Same constant, same reasoning as the bridge's own miss cooldown.
44548
+ */
44549
+ let optionsProbeCooldownUntil = 0;
44550
+ /** Persist the descriptor, but only when it actually MOVED — this runs on
44551
+ * every refresh and a per-refresh SQLite commit for an unchanged model
44552
+ * capability is pure churn. Best-effort: a failed write costs the next
44553
+ * restart one probe. */
44554
+ const persistOptions = async (next) => {
44555
+ const stored = this.config.get("deviceCache")?.privacyMaskOptions;
44556
+ if (stored && stored.maxRegions === next.maxRegions && stored.supportsAudioMute === next.supportsAudioMute) return;
44557
+ try {
44558
+ const previous = this.config.get("deviceCache") ?? {};
44559
+ await this.config.setAll({ deviceCache: {
44560
+ ...previous,
44561
+ privacyMaskOptions: {
44562
+ maxRegions: next.maxRegions,
44563
+ supportsAudioMute: next.supportsAudioMute,
44564
+ fetchedAt: Date.now()
44565
+ }
44566
+ } });
44567
+ } catch (err) {
44568
+ this.ctx.logger.debug("hikvision privacy-mask options persist failed", {
44569
+ tags: { deviceId: this.id },
44570
+ meta: { error: err instanceof Error ? err.message : String(err) }
44571
+ });
44572
+ }
44573
+ };
44260
44574
  /** Round-trip the privacy-mask config → cap regions, write slice. */
44261
44575
  const refreshFromCamera = async () => {
44262
44576
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
@@ -44264,6 +44578,15 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44264
44578
  try {
44265
44579
  const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44266
44580
  if (!mask) return;
44581
+ await persistOptions({
44582
+ maxRegions: mask.maxRegions > 0 ? mask.maxRegions : 4,
44583
+ supportedShapes: ["rect", "polygon"],
44584
+ polygonVertices: {
44585
+ min: 4,
44586
+ max: 4
44587
+ },
44588
+ supportsAudioMute: audio.length > 0
44589
+ });
44267
44590
  const regions = mask.regions.map((region, index) => ({
44268
44591
  id: index,
44269
44592
  enabled: true,
@@ -44290,22 +44613,25 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44290
44613
  this.privacyMaskRefreshInFlight = null;
44291
44614
  }
44292
44615
  };
44616
+ const bridge = createRuntimeStateBridge({
44617
+ runtimeState: this.runtimeState,
44618
+ cap: privacyMaskCapability,
44619
+ ownDeviceId: this.id,
44620
+ refresh: refreshFromCamera,
44621
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
44622
+ staleRead: "serve-and-revalidate",
44623
+ logger: this.ctx.logger,
44624
+ empty: () => ({
44625
+ enabled: false,
44626
+ regions: [],
44627
+ audioEnabled: null,
44628
+ lastFetchedAt: 0
44629
+ })
44630
+ });
44293
44631
  const provider = {
44294
- getStatus: createRuntimeStateBridge({
44295
- runtimeState: this.runtimeState,
44296
- cap: privacyMaskCapability,
44297
- ownDeviceId: this.id,
44298
- refresh: refreshFromCamera,
44299
- staleMs: STALE_MS,
44300
- empty: () => ({
44301
- enabled: false,
44302
- regions: [],
44303
- audioEnabled: null,
44304
- lastFetchedAt: 0
44305
- })
44306
- }).getStatus,
44632
+ getStatus: bridge.getStatus,
44307
44633
  getOptions: async ({ deviceId }) => {
44308
- const cold = {
44634
+ if (deviceId !== this.id) return {
44309
44635
  maxRegions: 4,
44310
44636
  supportedShapes: ["rect", "polygon"],
44311
44637
  polygonVertices: {
@@ -44314,17 +44640,16 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44314
44640
  },
44315
44641
  supportsAudioMute: false
44316
44642
  };
44317
- if (deviceId !== this.id) return cold;
44318
- const [mask, audio] = await Promise.all([isapi.getPrivacyMask(cameraNumber), this.readStreamAudioChannels(cameraNumber)]);
44319
- return {
44320
- maxRegions: mask && mask.maxRegions > 0 ? mask.maxRegions : 4,
44321
- supportedShapes: ["rect", "polygon"],
44322
- polygonVertices: {
44323
- min: 4,
44324
- max: 4
44325
- },
44326
- supportsAudioMute: audio.length > 0
44327
- };
44643
+ await bridge.ensureFresh();
44644
+ const warm = readPersistedOptions();
44645
+ if (warm !== null) return warm;
44646
+ if (Date.now() >= optionsProbeCooldownUntil) {
44647
+ optionsProbeCooldownUntil = Date.now() + RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS;
44648
+ await refreshFromCamera();
44649
+ const cold = readPersistedOptions();
44650
+ if (cold !== null) return cold;
44651
+ }
44652
+ throw new Error(`device ${String(this.id)}: privacy-mask options unknown — the camera has not answered`);
44328
44653
  },
44329
44654
  setMask: async ({ deviceId, patch }) => {
44330
44655
  if (deviceId !== this.id) return;