@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 +1 -1
- package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-DAdBzUD3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-Yo6BXk0X.mjs} +3 -3
- package/dist/{hostInit-BB5iEVB7.mjs → hostInit-Df_NHE4W.mjs} +2 -2
- package/dist/index.js +221 -18
- package/dist/index.mjs +221 -18
- package/dist/remoteEntry.js +1 -1
- package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-7VFK3Vnq.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-CsJNPlNP.mjs} +1 -1
- package/package.json +1 -1
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-
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
41673
|
-
*
|
|
41674
|
-
*
|
|
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.
|
|
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
|
|
41723
|
-
*
|
|
41724
|
-
*
|
|
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
|
|
41727
|
-
|
|
41728
|
-
|
|
41729
|
-
|
|
41730
|
-
|
|
41731
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
41669
|
-
*
|
|
41670
|
-
*
|
|
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.
|
|
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
|
|
41719
|
-
*
|
|
41720
|
-
*
|
|
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
|
|
41723
|
-
|
|
41724
|
-
|
|
41725
|
-
|
|
41726
|
-
|
|
41727
|
-
|
|
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
|
-
|
|
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
|
package/dist/remoteEntry.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-
|
|
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-
|
|
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
|
}
|