@camstack/addon-osd-manager 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/_stub.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./virtualExposes-BgZ5Cn7S.mjs";
2
- import "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-7VFK3Vnq.mjs";
2
+ import "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-CsJNPlNP.mjs";
3
3
  import { _ as e, c as t, d as n, h as r, i, l as a, m as o, n as s, p as c, r as l, t as u, u as d, y as f } from "./_virtual_mf___mfe_internal__addon_osd_manager_page__loadShare__react__loadShare__.js-Bs1t18EM.mjs";
4
4
  import { i as p, o as m, r as h, s as g } from "./responsive-B6685e2G.mjs";
5
5
  import { c as _, l as v, u as y } from "./use-device-snapshot-BDmozw2y.mjs";
@@ -1,4 +1,4 @@
1
- import "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-7VFK3Vnq.mjs";
1
+ import "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-CsJNPlNP.mjs";
2
2
  //#region \0virtual:mf-localSharedImportMap:__mfe_internal__addon_osd_manager_page
3
3
  var e = {
4
4
  "@camstack/sdk": {
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.89",
21
+ version: "1.2.90",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_osd_manager_page",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.61",
36
+ version: "1.2.62",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_osd_manager_page",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.89",
39
+ version: "1.2.90",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.61",
84
+ version: "1.2.62",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
package/dist/index.js CHANGED
@@ -41255,6 +41255,37 @@ var OsdBindingsStore = class {
41255
41255
  }
41256
41256
  };
41257
41257
  //#endregion
41258
+ //#region src/bound-index.ts
41259
+ /**
41260
+ * The durable index of cameras that have at least one OSD binding.
41261
+ *
41262
+ * WHY IT EXISTS. The render loop's work list used to be DERIVED at boot: list
41263
+ * every device in the directory, read each one's binding block, keep the ones
41264
+ * that answered non-empty. Every one of those reads is a UDS round-trip to the
41265
+ * settings store, and they ran one at a time. Measured on the boot of
41266
+ * 2026-08-19: 976 devices × 559 ms under boot load = 546 s in which
41267
+ * `OsdManager.start()` had not returned — and `start()` was awaited inside
41268
+ * `onInitialize()`, so for nine minutes the `osd-manager` and
41269
+ * `addon-pages-source` capabilities did not exist ANYWHERE in the cluster
41270
+ * (docs/architecture/addon-lifecycle.md). The answer it took nine minutes to
41271
+ * compute was `boundDevices = 0`.
41272
+ *
41273
+ * Filtering to cameras and fanning the reads out makes that scan cheap, but it
41274
+ * is still a scan. The index is the actual fix: the work list is WRITTEN where
41275
+ * bindings are written, so boot reads one key and is done in milliseconds. The
41276
+ * scan stays, in the background, as the reconcile — because an index nothing
41277
+ * checks is an index that eventually lies.
41278
+ *
41279
+ * The value is the SET of bound camera ids, serialised as a sorted array.
41280
+ * `null` is not "no cameras are bound", it is "this hub has never written the
41281
+ * index" — the two must stay distinguishable, because the first is authoritative
41282
+ * and the second means seed it from a scan. An empty ARRAY is the authoritative
41283
+ * "nothing is bound".
41284
+ */
41285
+ /** Store key inside the addon's own (global) store. */
41286
+ var OSD_BOUND_INDEX_KEY = "boundDeviceIds";
41287
+ var OsdBoundIndexSchema = array(number().int().positive()).nullable();
41288
+ //#endregion
41258
41289
  //#region src/gate.ts
41259
41290
  /**
41260
41291
  * The display gate — "should this slot show its value right now?".
@@ -41331,6 +41362,27 @@ function evaluateGate(input) {
41331
41362
  return OPEN;
41332
41363
  }
41333
41364
  //#endregion
41365
+ //#region src/lane-pool.ts
41366
+ /**
41367
+ * Apply `mapper` to every item with at most `limit` in flight at once.
41368
+ *
41369
+ * A rejecting mapper rejects the whole call (same contract as `Promise.all`);
41370
+ * a caller that must not fail the sweep for one bad unit catches INSIDE the
41371
+ * mapper — which is what every caller in this package does, because a unit that
41372
+ * threw must never be the reason the next camera is not reconciled.
41373
+ */
41374
+ async function mapWithConcurrency(items, limit, mapper) {
41375
+ const results = new Array(items.length);
41376
+ if (items.length === 0) return results;
41377
+ const lanes = Math.max(1, Math.min(Math.trunc(limit), items.length));
41378
+ const entries = items.entries();
41379
+ const lane = async () => {
41380
+ for (const [index, item] of entries) results[index] = await mapper(item, index);
41381
+ };
41382
+ await Promise.all(Array.from({ length: lanes }, () => lane()));
41383
+ return results;
41384
+ }
41385
+ //#endregion
41334
41386
  //#region src/format.ts
41335
41387
  /** Tokens `formatClock` understands. Anything else in the pattern is literal. */
41336
41388
  var CLOCK_TOKENS = [
@@ -41663,31 +41715,61 @@ var OsdManager = class {
41663
41715
  recognitionGeneration = /* @__PURE__ */ new Map();
41664
41716
  recognitionInvalidatedAt = /* @__PURE__ */ new Map();
41665
41717
  timer = null;
41718
+ /** The background reconcile, while one is running. Held so a second arming
41719
+ * cannot overlap the first — two concurrent passes would race on the index
41720
+ * write and could persist the older of the two answers. */
41721
+ reconcile = null;
41666
41722
  constructor(deps) {
41667
41723
  this.deps = deps;
41668
41724
  this.logger = deps.logger;
41669
41725
  this.now = deps.now ?? (() => Date.now());
41670
41726
  }
41671
41727
  /**
41672
- * Learn which cameras have bindings, then start ticking. A camera with no
41673
- * binding costs nothing — it is never in `boundDevices`, so the loop
41674
- * never reads its state and never touches its firmware.
41728
+ * Read the durable work list, start ticking, and arm the reconcile BEHIND
41729
+ * both.
41730
+ *
41731
+ * This used to derive the work list first: list every device, read every
41732
+ * device's binding block, one at a time. On 2026-08-19 that was 976 devices ×
41733
+ * 559 ms = 546 s, awaited inside `onInitialize`, so every capability this
41734
+ * addon declares was absent cluster-wide for nine minutes — and the answer was
41735
+ * `boundDevices = 0`. A camera with no binding still costs nothing at runtime;
41736
+ * what it used to cost was the boot.
41737
+ *
41738
+ * The scan did not go away, it moved: it is the reconcile, it runs in the
41739
+ * background, and it is the only thing that keeps the index from silently
41740
+ * lying.
41675
41741
  */
41676
41742
  async start(tickMs) {
41677
- await this.reloadBoundDevices();
41743
+ const stored = await this.readBoundIndex();
41744
+ if (stored !== null) {
41745
+ this.boundDevices.clear();
41746
+ for (const deviceId of stored) this.boundDevices.add(deviceId);
41747
+ }
41678
41748
  this.timer = setInterval(() => {
41679
41749
  this.renderAllBound();
41680
41750
  }, tickMs);
41681
41751
  this.logger.info("osd-manager: render loop started", { meta: {
41682
41752
  tickMs,
41683
- boundDevices: this.boundDevices.size
41753
+ boundDevices: this.boundDevices.size,
41754
+ source: stored === null ? "seed" : "durable-index"
41684
41755
  } });
41756
+ this.armReconcile(stored === null ? "seed" : "reconcile");
41685
41757
  }
41686
41758
  stop() {
41687
41759
  if (this.timer !== null) clearInterval(this.timer);
41688
41760
  this.timer = null;
41689
41761
  this.lastWritten.clear();
41690
41762
  }
41763
+ /** Resolves when the armed reconcile has settled. Tests await it; production
41764
+ * never does — that is the entire point of arming it. */
41765
+ async settleReconcile() {
41766
+ await this.reconcile;
41767
+ }
41768
+ /** Every camera currently on the work list, sorted — the value persisted as
41769
+ * the durable index. */
41770
+ get boundDeviceIds() {
41771
+ return [...this.boundDevices].sort((a, b) => a - b);
41772
+ }
41691
41773
  /**
41692
41774
  * Keep recognition overlays event-fresh. The persisted-track hydration below
41693
41775
  * remains the restart/cold-start authority; lifecycle events only advance it.
@@ -41719,19 +41801,124 @@ var OsdManager = class {
41719
41801
  });
41720
41802
  }
41721
41803
  /**
41722
- * Reconcile the work list against the store rather than trusting an event
41723
- * to have arrived (D8). Cheap: one settings read per camera, on the tick
41724
- * cadence, not per frame.
41804
+ * Reconcile the durable index against the store rather than trusting it to be
41805
+ * right (D8 an index nothing checks is an index that eventually lies).
41806
+ *
41807
+ * Background work, and BOUNDED: cameras only, four reads in flight. Two rules
41808
+ * keep it from destroying work:
41809
+ *
41810
+ * - a binding read that FAILED decides nothing — the camera keeps whatever the
41811
+ * index already said (D49);
41812
+ * - a camera absent from the listing is left alone. The device restore pass
41813
+ * runs for minutes after boot (D167), so "not published yet" and "deleted"
41814
+ * look identical from here, and only one of them may unbind a camera. A
41815
+ * real removal comes from `clearSlotBinding`, which is authoritative.
41816
+ *
41817
+ * Every correction is logged with its `deviceId`, because a silent repair is
41818
+ * indistinguishable from an index that was right all along.
41819
+ */
41820
+ async reconcileBoundDevices(mode = "reconcile") {
41821
+ const cameras = await this.deps.listCameras();
41822
+ const probes = await mapWithConcurrency(cameras, this.deps.reconcileConcurrency ?? 4, async (camera) => ({
41823
+ deviceId: camera.id,
41824
+ bindings: await this.probeBindings(camera.id)
41825
+ }));
41826
+ const listed = new Set(cameras.map((camera) => camera.id));
41827
+ let unreadable = 0;
41828
+ const added = [];
41829
+ const removed = [];
41830
+ for (const probe of probes) {
41831
+ if (probe.bindings === null) {
41832
+ unreadable += 1;
41833
+ continue;
41834
+ }
41835
+ const bound = Object.keys(probe.bindings).length > 0;
41836
+ if (bound && !this.boundDevices.has(probe.deviceId)) {
41837
+ this.boundDevices.add(probe.deviceId);
41838
+ added.push(probe.deviceId);
41839
+ } else if (!bound && this.boundDevices.has(probe.deviceId)) {
41840
+ this.boundDevices.delete(probe.deviceId);
41841
+ removed.push(probe.deviceId);
41842
+ }
41843
+ }
41844
+ if (mode === "reconcile") {
41845
+ for (const deviceId of added) this.logger.warn("osd-manager: bound-device index was missing a camera that HAS bindings — corrected", {
41846
+ tags: { deviceId },
41847
+ meta: { correction: "added" }
41848
+ });
41849
+ for (const deviceId of removed) this.logger.warn("osd-manager: bound-device index claimed a camera with NO bindings — corrected", {
41850
+ tags: { deviceId },
41851
+ meta: { correction: "removed" }
41852
+ });
41853
+ }
41854
+ const notListed = [...this.boundDevices].filter((deviceId) => !listed.has(deviceId)).length;
41855
+ this.logger.info("osd-manager: bound-device reconcile complete", { meta: {
41856
+ mode,
41857
+ cameras: cameras.length,
41858
+ bound: this.boundDevices.size,
41859
+ added: added.length,
41860
+ removed: removed.length,
41861
+ unreadable,
41862
+ notListed
41863
+ } });
41864
+ if (mode === "seed" || added.length > 0 || removed.length > 0) await this.persistBoundIndex(null);
41865
+ }
41866
+ /**
41867
+ * Run the reconcile in the BACKGROUND, once at a time.
41868
+ *
41869
+ * Nothing on the boot path may await it — that await is what took the
41870
+ * capability graph down for nine minutes. A second arming while one is in
41871
+ * flight is dropped rather than queued: both passes would compute over the
41872
+ * same store and race on the index write.
41873
+ */
41874
+ armReconcile(mode) {
41875
+ if (this.reconcile !== null) return;
41876
+ this.reconcile = this.reconcileBoundDevices(mode).catch((err) => {
41877
+ this.logger.warn("osd-manager: bound-device reconcile failed — the index keeps its current contents", { meta: {
41878
+ mode,
41879
+ error: String(err)
41880
+ } });
41881
+ }).finally(() => {
41882
+ this.reconcile = null;
41883
+ });
41884
+ }
41885
+ /** The persisted index, or `null` when it has never been written (or could
41886
+ * not be read — both mean "derive it from a scan", which is safe). */
41887
+ async readBoundIndex() {
41888
+ try {
41889
+ return await this.deps.boundIndex.get();
41890
+ } catch (err) {
41891
+ this.logger.warn("osd-manager: bound-device index unreadable — seeding from a camera scan", { meta: { error: String(err) } });
41892
+ return null;
41893
+ }
41894
+ }
41895
+ /**
41896
+ * Persist the work list. Called from every place a binding is written, so the
41897
+ * index cannot drift from the bindings without the write that caused the drift
41898
+ * also failing — and that failure is logged, and repaired by the reconcile.
41725
41899
  */
41726
- async reloadBoundDevices() {
41727
- const devices = await this.safeListDevices();
41728
- const next = /* @__PURE__ */ new Set();
41729
- for (const device of devices) {
41730
- const bindings = await this.readBindings(device.id);
41731
- if (Object.keys(bindings).length > 0) next.add(device.id);
41900
+ async persistBoundIndex(deviceId) {
41901
+ try {
41902
+ await this.deps.boundIndex.set([...this.boundDeviceIds]);
41903
+ } catch (err) {
41904
+ this.logger.warn("osd-manager: bound-device index write failed — the next reconcile repairs it", {
41905
+ ...deviceId === null ? {} : { tags: { deviceId } },
41906
+ meta: { error: String(err) }
41907
+ });
41908
+ }
41909
+ }
41910
+ /** One camera's bindings, or `null` when the read FAILED. Distinct from an
41911
+ * empty map on purpose: only the second may unbind a camera (D49). */
41912
+ async probeBindings(deviceId) {
41913
+ try {
41914
+ return await this.deps.bindings(deviceId).get();
41915
+ } catch (err) {
41916
+ this.logger.warn("osd-manager: binding read failed during reconcile — this camera keeps its index entry", {
41917
+ tags: { deviceId },
41918
+ meta: { error: String(err) }
41919
+ });
41920
+ return null;
41732
41921
  }
41733
- this.boundDevices.clear();
41734
- for (const id of next) this.boundDevices.add(id);
41735
41922
  }
41736
41923
  getDeviceOsd = async ({ deviceId }) => {
41737
41924
  const status = await this.deps.readOsdStatus(deviceId);
@@ -41782,6 +41969,7 @@ var OsdManager = class {
41782
41969
  await this.assertSlotExists(deviceId, slotId);
41783
41970
  await this.deps.bindings(deviceId).update((prev) => withBinding(prev, slotId, binding));
41784
41971
  this.boundDevices.add(deviceId);
41972
+ await this.persistBoundIndex(deviceId);
41785
41973
  this.logger.info("osd-manager: slot binding saved", {
41786
41974
  tags: { deviceId },
41787
41975
  meta: {
@@ -41806,6 +41994,7 @@ var OsdManager = class {
41806
41994
  this.lastWritten.delete(cacheKey(deviceId, slotId));
41807
41995
  const remaining = await this.readBindings(deviceId);
41808
41996
  if (Object.keys(remaining).length === 0) this.boundDevices.delete(deviceId);
41997
+ await this.persistBoundIndex(deviceId);
41809
41998
  this.logger.info("osd-manager: slot binding cleared — slot keeps its last text", {
41810
41999
  tags: { deviceId },
41811
42000
  meta: { slotId }
@@ -41846,6 +42035,7 @@ var OsdManager = class {
41846
42035
  for (const key of [...this.lastWritten.keys()]) if (key.startsWith(`${targetDeviceId}:`)) this.lastWritten.delete(key);
41847
42036
  if (Object.keys(copiedBindings).length > 0) this.boundDevices.add(targetDeviceId);
41848
42037
  else this.boundDevices.delete(targetDeviceId);
42038
+ await this.persistBoundIndex(targetDeviceId);
41849
42039
  await this.renderDeviceInternal(targetDeviceId, null);
41850
42040
  this.logger.info("osd-manager: camera configuration copied", {
41851
42041
  tags: { deviceId: targetDeviceId },
@@ -42074,6 +42264,7 @@ var OsdManagerAddon = class extends BaseAddon {
42074
42264
  async onInitialize() {
42075
42265
  const ctx = this.ctx;
42076
42266
  const store = new OsdBindingsStore((deviceId, key, schema, fallback) => this.deviceState(deviceId, key, schema, fallback));
42267
+ const boundIndex = this.state(OSD_BOUND_INDEX_KEY, OsdBoundIndexSchema, null);
42077
42268
  const manager = new OsdManager({
42078
42269
  logger: ctx.logger,
42079
42270
  readOsdStatus: async (deviceId) => {
@@ -42110,6 +42301,15 @@ var OsdManagerAddon = class extends BaseAddon {
42110
42301
  name: d.name
42111
42302
  }));
42112
42303
  },
42304
+ listCameras: async () => {
42305
+ return (await ctx.api.deviceManager.listAll.query({
42306
+ isCamera: true,
42307
+ projection: "slim"
42308
+ })).map((d) => ({
42309
+ id: d.id,
42310
+ name: d.name
42311
+ }));
42312
+ },
42113
42313
  listDeviceCaps: async (deviceId) => {
42114
42314
  const bindings = await ctx.api.deviceManager.getBindings.query({ deviceId });
42115
42315
  return [...new Set(bindings.entries.map((e) => e.capName))];
@@ -42137,7 +42337,8 @@ var OsdManagerAddon = class extends BaseAddon {
42137
42337
  } } : {}
42138
42338
  };
42139
42339
  },
42140
- bindings: (deviceId) => store.forDevice(deviceId)
42340
+ bindings: (deviceId) => store.forDevice(deviceId),
42341
+ boundIndex
42141
42342
  });
42142
42343
  this.manager = manager;
42143
42344
  this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
@@ -42149,7 +42350,9 @@ var OsdManagerAddon = class extends BaseAddon {
42149
42350
  this.subscribe({ category: EventCategory.PipelineAnalyticsPlateGalleryChanged }, (event) => {
42150
42351
  manager.onRecognitionGalleryChanged(event.data.deviceId);
42151
42352
  });
42152
- await manager.start(Math.max(1, this.config.tickSeconds) * 1e3);
42353
+ manager.start(Math.max(1, this.config.tickSeconds) * 1e3).catch((err) => {
42354
+ ctx.logger.error("osd-manager: render loop failed to start — no camera will be re-rendered", { meta: { error: err instanceof Error ? err.message : String(err) } });
42355
+ });
42153
42356
  const pagesProvider = {
42154
42357
  id: this.id,
42155
42358
  listPages: () => this.pages
package/dist/index.mjs CHANGED
@@ -41251,6 +41251,37 @@ var OsdBindingsStore = class {
41251
41251
  }
41252
41252
  };
41253
41253
  //#endregion
41254
+ //#region src/bound-index.ts
41255
+ /**
41256
+ * The durable index of cameras that have at least one OSD binding.
41257
+ *
41258
+ * WHY IT EXISTS. The render loop's work list used to be DERIVED at boot: list
41259
+ * every device in the directory, read each one's binding block, keep the ones
41260
+ * that answered non-empty. Every one of those reads is a UDS round-trip to the
41261
+ * settings store, and they ran one at a time. Measured on the boot of
41262
+ * 2026-08-19: 976 devices × 559 ms under boot load = 546 s in which
41263
+ * `OsdManager.start()` had not returned — and `start()` was awaited inside
41264
+ * `onInitialize()`, so for nine minutes the `osd-manager` and
41265
+ * `addon-pages-source` capabilities did not exist ANYWHERE in the cluster
41266
+ * (docs/architecture/addon-lifecycle.md). The answer it took nine minutes to
41267
+ * compute was `boundDevices = 0`.
41268
+ *
41269
+ * Filtering to cameras and fanning the reads out makes that scan cheap, but it
41270
+ * is still a scan. The index is the actual fix: the work list is WRITTEN where
41271
+ * bindings are written, so boot reads one key and is done in milliseconds. The
41272
+ * scan stays, in the background, as the reconcile — because an index nothing
41273
+ * checks is an index that eventually lies.
41274
+ *
41275
+ * The value is the SET of bound camera ids, serialised as a sorted array.
41276
+ * `null` is not "no cameras are bound", it is "this hub has never written the
41277
+ * index" — the two must stay distinguishable, because the first is authoritative
41278
+ * and the second means seed it from a scan. An empty ARRAY is the authoritative
41279
+ * "nothing is bound".
41280
+ */
41281
+ /** Store key inside the addon's own (global) store. */
41282
+ var OSD_BOUND_INDEX_KEY = "boundDeviceIds";
41283
+ var OsdBoundIndexSchema = array(number().int().positive()).nullable();
41284
+ //#endregion
41254
41285
  //#region src/gate.ts
41255
41286
  /**
41256
41287
  * The display gate — "should this slot show its value right now?".
@@ -41327,6 +41358,27 @@ function evaluateGate(input) {
41327
41358
  return OPEN;
41328
41359
  }
41329
41360
  //#endregion
41361
+ //#region src/lane-pool.ts
41362
+ /**
41363
+ * Apply `mapper` to every item with at most `limit` in flight at once.
41364
+ *
41365
+ * A rejecting mapper rejects the whole call (same contract as `Promise.all`);
41366
+ * a caller that must not fail the sweep for one bad unit catches INSIDE the
41367
+ * mapper — which is what every caller in this package does, because a unit that
41368
+ * threw must never be the reason the next camera is not reconciled.
41369
+ */
41370
+ async function mapWithConcurrency(items, limit, mapper) {
41371
+ const results = new Array(items.length);
41372
+ if (items.length === 0) return results;
41373
+ const lanes = Math.max(1, Math.min(Math.trunc(limit), items.length));
41374
+ const entries = items.entries();
41375
+ const lane = async () => {
41376
+ for (const [index, item] of entries) results[index] = await mapper(item, index);
41377
+ };
41378
+ await Promise.all(Array.from({ length: lanes }, () => lane()));
41379
+ return results;
41380
+ }
41381
+ //#endregion
41330
41382
  //#region src/format.ts
41331
41383
  /** Tokens `formatClock` understands. Anything else in the pattern is literal. */
41332
41384
  var CLOCK_TOKENS = [
@@ -41659,31 +41711,61 @@ var OsdManager = class {
41659
41711
  recognitionGeneration = /* @__PURE__ */ new Map();
41660
41712
  recognitionInvalidatedAt = /* @__PURE__ */ new Map();
41661
41713
  timer = null;
41714
+ /** The background reconcile, while one is running. Held so a second arming
41715
+ * cannot overlap the first — two concurrent passes would race on the index
41716
+ * write and could persist the older of the two answers. */
41717
+ reconcile = null;
41662
41718
  constructor(deps) {
41663
41719
  this.deps = deps;
41664
41720
  this.logger = deps.logger;
41665
41721
  this.now = deps.now ?? (() => Date.now());
41666
41722
  }
41667
41723
  /**
41668
- * Learn which cameras have bindings, then start ticking. A camera with no
41669
- * binding costs nothing — it is never in `boundDevices`, so the loop
41670
- * never reads its state and never touches its firmware.
41724
+ * Read the durable work list, start ticking, and arm the reconcile BEHIND
41725
+ * both.
41726
+ *
41727
+ * This used to derive the work list first: list every device, read every
41728
+ * device's binding block, one at a time. On 2026-08-19 that was 976 devices ×
41729
+ * 559 ms = 546 s, awaited inside `onInitialize`, so every capability this
41730
+ * addon declares was absent cluster-wide for nine minutes — and the answer was
41731
+ * `boundDevices = 0`. A camera with no binding still costs nothing at runtime;
41732
+ * what it used to cost was the boot.
41733
+ *
41734
+ * The scan did not go away, it moved: it is the reconcile, it runs in the
41735
+ * background, and it is the only thing that keeps the index from silently
41736
+ * lying.
41671
41737
  */
41672
41738
  async start(tickMs) {
41673
- await this.reloadBoundDevices();
41739
+ const stored = await this.readBoundIndex();
41740
+ if (stored !== null) {
41741
+ this.boundDevices.clear();
41742
+ for (const deviceId of stored) this.boundDevices.add(deviceId);
41743
+ }
41674
41744
  this.timer = setInterval(() => {
41675
41745
  this.renderAllBound();
41676
41746
  }, tickMs);
41677
41747
  this.logger.info("osd-manager: render loop started", { meta: {
41678
41748
  tickMs,
41679
- boundDevices: this.boundDevices.size
41749
+ boundDevices: this.boundDevices.size,
41750
+ source: stored === null ? "seed" : "durable-index"
41680
41751
  } });
41752
+ this.armReconcile(stored === null ? "seed" : "reconcile");
41681
41753
  }
41682
41754
  stop() {
41683
41755
  if (this.timer !== null) clearInterval(this.timer);
41684
41756
  this.timer = null;
41685
41757
  this.lastWritten.clear();
41686
41758
  }
41759
+ /** Resolves when the armed reconcile has settled. Tests await it; production
41760
+ * never does — that is the entire point of arming it. */
41761
+ async settleReconcile() {
41762
+ await this.reconcile;
41763
+ }
41764
+ /** Every camera currently on the work list, sorted — the value persisted as
41765
+ * the durable index. */
41766
+ get boundDeviceIds() {
41767
+ return [...this.boundDevices].sort((a, b) => a - b);
41768
+ }
41687
41769
  /**
41688
41770
  * Keep recognition overlays event-fresh. The persisted-track hydration below
41689
41771
  * remains the restart/cold-start authority; lifecycle events only advance it.
@@ -41715,19 +41797,124 @@ var OsdManager = class {
41715
41797
  });
41716
41798
  }
41717
41799
  /**
41718
- * Reconcile the work list against the store rather than trusting an event
41719
- * to have arrived (D8). Cheap: one settings read per camera, on the tick
41720
- * cadence, not per frame.
41800
+ * Reconcile the durable index against the store rather than trusting it to be
41801
+ * right (D8 an index nothing checks is an index that eventually lies).
41802
+ *
41803
+ * Background work, and BOUNDED: cameras only, four reads in flight. Two rules
41804
+ * keep it from destroying work:
41805
+ *
41806
+ * - a binding read that FAILED decides nothing — the camera keeps whatever the
41807
+ * index already said (D49);
41808
+ * - a camera absent from the listing is left alone. The device restore pass
41809
+ * runs for minutes after boot (D167), so "not published yet" and "deleted"
41810
+ * look identical from here, and only one of them may unbind a camera. A
41811
+ * real removal comes from `clearSlotBinding`, which is authoritative.
41812
+ *
41813
+ * Every correction is logged with its `deviceId`, because a silent repair is
41814
+ * indistinguishable from an index that was right all along.
41815
+ */
41816
+ async reconcileBoundDevices(mode = "reconcile") {
41817
+ const cameras = await this.deps.listCameras();
41818
+ const probes = await mapWithConcurrency(cameras, this.deps.reconcileConcurrency ?? 4, async (camera) => ({
41819
+ deviceId: camera.id,
41820
+ bindings: await this.probeBindings(camera.id)
41821
+ }));
41822
+ const listed = new Set(cameras.map((camera) => camera.id));
41823
+ let unreadable = 0;
41824
+ const added = [];
41825
+ const removed = [];
41826
+ for (const probe of probes) {
41827
+ if (probe.bindings === null) {
41828
+ unreadable += 1;
41829
+ continue;
41830
+ }
41831
+ const bound = Object.keys(probe.bindings).length > 0;
41832
+ if (bound && !this.boundDevices.has(probe.deviceId)) {
41833
+ this.boundDevices.add(probe.deviceId);
41834
+ added.push(probe.deviceId);
41835
+ } else if (!bound && this.boundDevices.has(probe.deviceId)) {
41836
+ this.boundDevices.delete(probe.deviceId);
41837
+ removed.push(probe.deviceId);
41838
+ }
41839
+ }
41840
+ if (mode === "reconcile") {
41841
+ for (const deviceId of added) this.logger.warn("osd-manager: bound-device index was missing a camera that HAS bindings — corrected", {
41842
+ tags: { deviceId },
41843
+ meta: { correction: "added" }
41844
+ });
41845
+ for (const deviceId of removed) this.logger.warn("osd-manager: bound-device index claimed a camera with NO bindings — corrected", {
41846
+ tags: { deviceId },
41847
+ meta: { correction: "removed" }
41848
+ });
41849
+ }
41850
+ const notListed = [...this.boundDevices].filter((deviceId) => !listed.has(deviceId)).length;
41851
+ this.logger.info("osd-manager: bound-device reconcile complete", { meta: {
41852
+ mode,
41853
+ cameras: cameras.length,
41854
+ bound: this.boundDevices.size,
41855
+ added: added.length,
41856
+ removed: removed.length,
41857
+ unreadable,
41858
+ notListed
41859
+ } });
41860
+ if (mode === "seed" || added.length > 0 || removed.length > 0) await this.persistBoundIndex(null);
41861
+ }
41862
+ /**
41863
+ * Run the reconcile in the BACKGROUND, once at a time.
41864
+ *
41865
+ * Nothing on the boot path may await it — that await is what took the
41866
+ * capability graph down for nine minutes. A second arming while one is in
41867
+ * flight is dropped rather than queued: both passes would compute over the
41868
+ * same store and race on the index write.
41869
+ */
41870
+ armReconcile(mode) {
41871
+ if (this.reconcile !== null) return;
41872
+ this.reconcile = this.reconcileBoundDevices(mode).catch((err) => {
41873
+ this.logger.warn("osd-manager: bound-device reconcile failed — the index keeps its current contents", { meta: {
41874
+ mode,
41875
+ error: String(err)
41876
+ } });
41877
+ }).finally(() => {
41878
+ this.reconcile = null;
41879
+ });
41880
+ }
41881
+ /** The persisted index, or `null` when it has never been written (or could
41882
+ * not be read — both mean "derive it from a scan", which is safe). */
41883
+ async readBoundIndex() {
41884
+ try {
41885
+ return await this.deps.boundIndex.get();
41886
+ } catch (err) {
41887
+ this.logger.warn("osd-manager: bound-device index unreadable — seeding from a camera scan", { meta: { error: String(err) } });
41888
+ return null;
41889
+ }
41890
+ }
41891
+ /**
41892
+ * Persist the work list. Called from every place a binding is written, so the
41893
+ * index cannot drift from the bindings without the write that caused the drift
41894
+ * also failing — and that failure is logged, and repaired by the reconcile.
41721
41895
  */
41722
- async reloadBoundDevices() {
41723
- const devices = await this.safeListDevices();
41724
- const next = /* @__PURE__ */ new Set();
41725
- for (const device of devices) {
41726
- const bindings = await this.readBindings(device.id);
41727
- if (Object.keys(bindings).length > 0) next.add(device.id);
41896
+ async persistBoundIndex(deviceId) {
41897
+ try {
41898
+ await this.deps.boundIndex.set([...this.boundDeviceIds]);
41899
+ } catch (err) {
41900
+ this.logger.warn("osd-manager: bound-device index write failed — the next reconcile repairs it", {
41901
+ ...deviceId === null ? {} : { tags: { deviceId } },
41902
+ meta: { error: String(err) }
41903
+ });
41904
+ }
41905
+ }
41906
+ /** One camera's bindings, or `null` when the read FAILED. Distinct from an
41907
+ * empty map on purpose: only the second may unbind a camera (D49). */
41908
+ async probeBindings(deviceId) {
41909
+ try {
41910
+ return await this.deps.bindings(deviceId).get();
41911
+ } catch (err) {
41912
+ this.logger.warn("osd-manager: binding read failed during reconcile — this camera keeps its index entry", {
41913
+ tags: { deviceId },
41914
+ meta: { error: String(err) }
41915
+ });
41916
+ return null;
41728
41917
  }
41729
- this.boundDevices.clear();
41730
- for (const id of next) this.boundDevices.add(id);
41731
41918
  }
41732
41919
  getDeviceOsd = async ({ deviceId }) => {
41733
41920
  const status = await this.deps.readOsdStatus(deviceId);
@@ -41778,6 +41965,7 @@ var OsdManager = class {
41778
41965
  await this.assertSlotExists(deviceId, slotId);
41779
41966
  await this.deps.bindings(deviceId).update((prev) => withBinding(prev, slotId, binding));
41780
41967
  this.boundDevices.add(deviceId);
41968
+ await this.persistBoundIndex(deviceId);
41781
41969
  this.logger.info("osd-manager: slot binding saved", {
41782
41970
  tags: { deviceId },
41783
41971
  meta: {
@@ -41802,6 +41990,7 @@ var OsdManager = class {
41802
41990
  this.lastWritten.delete(cacheKey(deviceId, slotId));
41803
41991
  const remaining = await this.readBindings(deviceId);
41804
41992
  if (Object.keys(remaining).length === 0) this.boundDevices.delete(deviceId);
41993
+ await this.persistBoundIndex(deviceId);
41805
41994
  this.logger.info("osd-manager: slot binding cleared — slot keeps its last text", {
41806
41995
  tags: { deviceId },
41807
41996
  meta: { slotId }
@@ -41842,6 +42031,7 @@ var OsdManager = class {
41842
42031
  for (const key of [...this.lastWritten.keys()]) if (key.startsWith(`${targetDeviceId}:`)) this.lastWritten.delete(key);
41843
42032
  if (Object.keys(copiedBindings).length > 0) this.boundDevices.add(targetDeviceId);
41844
42033
  else this.boundDevices.delete(targetDeviceId);
42034
+ await this.persistBoundIndex(targetDeviceId);
41845
42035
  await this.renderDeviceInternal(targetDeviceId, null);
41846
42036
  this.logger.info("osd-manager: camera configuration copied", {
41847
42037
  tags: { deviceId: targetDeviceId },
@@ -42070,6 +42260,7 @@ var OsdManagerAddon = class extends BaseAddon {
42070
42260
  async onInitialize() {
42071
42261
  const ctx = this.ctx;
42072
42262
  const store = new OsdBindingsStore((deviceId, key, schema, fallback) => this.deviceState(deviceId, key, schema, fallback));
42263
+ const boundIndex = this.state(OSD_BOUND_INDEX_KEY, OsdBoundIndexSchema, null);
42073
42264
  const manager = new OsdManager({
42074
42265
  logger: ctx.logger,
42075
42266
  readOsdStatus: async (deviceId) => {
@@ -42106,6 +42297,15 @@ var OsdManagerAddon = class extends BaseAddon {
42106
42297
  name: d.name
42107
42298
  }));
42108
42299
  },
42300
+ listCameras: async () => {
42301
+ return (await ctx.api.deviceManager.listAll.query({
42302
+ isCamera: true,
42303
+ projection: "slim"
42304
+ })).map((d) => ({
42305
+ id: d.id,
42306
+ name: d.name
42307
+ }));
42308
+ },
42109
42309
  listDeviceCaps: async (deviceId) => {
42110
42310
  const bindings = await ctx.api.deviceManager.getBindings.query({ deviceId });
42111
42311
  return [...new Set(bindings.entries.map((e) => e.capName))];
@@ -42133,7 +42333,8 @@ var OsdManagerAddon = class extends BaseAddon {
42133
42333
  } } : {}
42134
42334
  };
42135
42335
  },
42136
- bindings: (deviceId) => store.forDevice(deviceId)
42336
+ bindings: (deviceId) => store.forDevice(deviceId),
42337
+ boundIndex
42137
42338
  });
42138
42339
  this.manager = manager;
42139
42340
  this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
@@ -42145,7 +42346,9 @@ var OsdManagerAddon = class extends BaseAddon {
42145
42346
  this.subscribe({ category: EventCategory.PipelineAnalyticsPlateGalleryChanged }, (event) => {
42146
42347
  manager.onRecognitionGalleryChanged(event.data.deviceId);
42147
42348
  });
42148
- await manager.start(Math.max(1, this.config.tickSeconds) * 1e3);
42349
+ manager.start(Math.max(1, this.config.tickSeconds) * 1e3).catch((err) => {
42350
+ ctx.logger.error("osd-manager: render loop failed to start — no camera will be re-rendered", { meta: { error: err instanceof Error ? err.message : String(err) } });
42351
+ });
42149
42352
  const pagesProvider = {
42150
42353
  id: this.id,
42151
42354
  listPages: () => this.pages
@@ -1,2 +1,2 @@
1
- import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-7VFK3Vnq.mjs";
1
+ import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-CsJNPlNP.mjs";
2
2
  export { t as get, e as init };
@@ -2753,7 +2753,7 @@ async function rr(e) {
2753
2753
  }
2754
2754
  }
2755
2755
  async function ir() {
2756
- return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-DAdBzUD3.mjs")).catch((e) => {
2756
+ return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-Yo6BXk0X.mjs")).catch((e) => {
2757
2757
  throw tr = void 0, e;
2758
2758
  }), tr;
2759
2759
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-osd-manager",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Binds camera on-screen-display slots to live state and recognitions",
5
5
  "keywords": [
6
6
  "camstack",