@camstack/system 1.2.253 → 1.2.255
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/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/autotrack-cameras.d.ts +76 -0
- package/dist/builtins/autotrack/autotrack-config.d.ts +89 -0
- package/dist/builtins/autotrack/autotrack-decision.d.ts +99 -0
- package/dist/builtins/autotrack/autotrack-loop.d.ts +75 -0
- package/dist/builtins/autotrack/autotrack.addon.d.ts +71 -0
- package/dist/builtins/autotrack/frame-error.d.ts +50 -0
- package/dist/builtins/autotrack/frame-measurements.d.ts +97 -0
- package/dist/builtins/autotrack/index.d.ts +2 -0
- package/dist/builtins/autotrack/index.js +981 -0
- package/dist/builtins/autotrack/index.mjs +975 -0
- package/dist/builtins/autotrack/ptz-mirror.d.ts +25 -0
- package/dist/builtins/autotrack/target-selection.d.ts +60 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/camera-grid/addon.d.ts +118 -0
- package/dist/builtins/camera-grid/flv-join.d.ts +66 -0
- package/dist/builtins/camera-grid/grid-camera-declarations.d.ts +59 -0
- package/dist/builtins/camera-grid/grid-camera-device.d.ts +32 -0
- package/dist/builtins/camera-grid/grid-child.d.ts +9 -0
- package/dist/builtins/camera-grid/grid-device-settings.d.ts +11 -0
- package/dist/builtins/camera-grid/grid-filter-graph.d.ts +53 -0
- package/dist/builtins/camera-grid/grid-frame-sample.d.ts +27 -0
- package/dist/builtins/camera-grid/grid-instances.d.ts +49 -0
- package/dist/builtins/camera-grid/grid-last-frame-store.d.ts +19 -0
- package/dist/builtins/camera-grid/grid-layout-native-provider.d.ts +8 -0
- package/dist/builtins/camera-grid/grid-plan.d.ts +22 -0
- package/dist/builtins/camera-grid/grid-profiles.d.ts +47 -0
- package/dist/builtins/camera-grid/grid-row-normalization.d.ts +10 -0
- package/dist/builtins/camera-grid/grid-sentry-sources.d.ts +52 -0
- package/dist/builtins/camera-grid/grid-snapshot.d.ts +62 -0
- package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +24 -0
- package/dist/builtins/camera-grid/grid-stream-invocation.d.ts +29 -0
- package/dist/builtins/camera-grid/grid-stream-relay.d.ts +36 -0
- package/dist/builtins/camera-grid/grid-stream-session.d.ts +113 -0
- package/dist/builtins/camera-grid/grid-wire-format.d.ts +14 -0
- package/dist/builtins/camera-grid/index.d.ts +44 -0
- package/dist/builtins/camera-grid/index.js +2309 -0
- package/dist/builtins/camera-grid/index.mjs +2276 -0
- package/dist/builtins/camera-grid/silence-analysis.d.ts +13 -0
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-B9y-3rme.js → dist-BJj6Akye.js} +1347 -13
- package/dist/{dist-DH6ThyTd.mjs → dist-Ch93tyHB.mjs} +1288 -14
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-CnMg7wvS.js → retired-settings-keys-BRCn6-mt.js} +1 -1
- package/dist/{retired-settings-keys-DNl1lrr_.mjs → retired-settings-keys-uCaSKuwd.mjs} +1 -1
- package/package.json +32 -1
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
require("../../chunk-Cek0wNdY.js");
|
|
6
|
+
const require_dist = require("../../dist-BJj6Akye.js");
|
|
7
|
+
//#region src/builtins/autotrack/autotrack-config.ts
|
|
8
|
+
var DEFAULT_AUTOTRACK_LOOP_CONFIG = {
|
|
9
|
+
deadZoneFraction: .5,
|
|
10
|
+
settleZoneFraction: .3,
|
|
11
|
+
maxCommandsPerSecond: 1 / 3,
|
|
12
|
+
pulseSettleMs: 400,
|
|
13
|
+
disappearDelayMs: 15e3,
|
|
14
|
+
speed: .4,
|
|
15
|
+
mirrorRefreshMs: 3e4,
|
|
16
|
+
measurementSummaryMs: 3e4
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/builtins/autotrack/autotrack-cameras.ts
|
|
20
|
+
/**
|
|
21
|
+
* What this addon persists per camera, and how the cap's cross-vendor settings
|
|
22
|
+
* become this loop's tuning.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the record lives here and not in the cap's runtime state
|
|
25
|
+
*
|
|
26
|
+
* `ptz-autotrack` declares `durability: 'session'` — its slice MIRRORS a
|
|
27
|
+
* camera's own autotrack config and is re-read on connect. For a native
|
|
28
|
+
* provider that is right: the firmware is the record. For the emulated
|
|
29
|
+
* implementation there is no firmware to re-read, so the slice would come back
|
|
30
|
+
* empty on every restart and a camera the operator armed would come up
|
|
31
|
+
* disarmed, silently. The record is therefore ours, in the addon's own
|
|
32
|
+
* settings, and the cap's `getStatus` reads it.
|
|
33
|
+
*
|
|
34
|
+
* ## One switch, not two (D62, D520)
|
|
35
|
+
*
|
|
36
|
+
* Binding this wrapper to a camera chooses the IMPLEMENTATION — the same act as
|
|
37
|
+
* binding `virtual-doorbell`. `setEnabled` is the switch for the function, and
|
|
38
|
+
* it is the cap's own switch: a camera with firmware autotrack uses its native
|
|
39
|
+
* provider for exactly the same method, so the operator never sees two
|
|
40
|
+
* controls that can disagree.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The defaults a camera starts from.
|
|
44
|
+
*
|
|
45
|
+
* `disappearDelaySeconds` matches the loop's own default so the two cannot
|
|
46
|
+
* disagree on the day nobody has opened the form. `stopDelaySeconds` is
|
|
47
|
+
* persisted and NOT honoured — see {@link loopConfigFor}.
|
|
48
|
+
*/
|
|
49
|
+
var DEFAULT_CAMERA_RECORD = {
|
|
50
|
+
enabled: false,
|
|
51
|
+
settings: {
|
|
52
|
+
targetType: "",
|
|
53
|
+
stopDelaySeconds: 30,
|
|
54
|
+
disappearDelaySeconds: DEFAULT_AUTOTRACK_LOOP_CONFIG.disappearDelayMs / 1e3
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* What this implementation can follow, as the cap's target options.
|
|
59
|
+
*
|
|
60
|
+
* A vendor list would be a lie here: the emulated loop follows what the
|
|
61
|
+
* DETECTOR emits, and the detector's first-level macro classes are these
|
|
62
|
+
* three. Reolink's `people` and Hikvision's `human` are vendor words for the
|
|
63
|
+
* same thing and are normalised in {@link targetClassFor} rather than offered.
|
|
64
|
+
*/
|
|
65
|
+
var EMULATED_TARGET_OPTIONS = require_dist.FIRST_LEVEL_MACRO_CLASSES.map((macro) => ({
|
|
66
|
+
value: macro,
|
|
67
|
+
label: macro === "person" ? "People" : macro === "vehicle" ? "Vehicles" : "Animals"
|
|
68
|
+
}));
|
|
69
|
+
function isRecordObject(value) {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
function parseSettings(raw) {
|
|
73
|
+
if (!isRecordObject(raw)) return DEFAULT_CAMERA_RECORD.settings;
|
|
74
|
+
const targetType = raw["targetType"];
|
|
75
|
+
const stopDelaySeconds = raw["stopDelaySeconds"];
|
|
76
|
+
const disappearDelaySeconds = raw["disappearDelaySeconds"];
|
|
77
|
+
return {
|
|
78
|
+
targetType: typeof targetType === "string" ? targetType : DEFAULT_CAMERA_RECORD.settings.targetType,
|
|
79
|
+
stopDelaySeconds: typeof stopDelaySeconds === "number" && Number.isFinite(stopDelaySeconds) ? stopDelaySeconds : DEFAULT_CAMERA_RECORD.settings.stopDelaySeconds,
|
|
80
|
+
disappearDelaySeconds: typeof disappearDelaySeconds === "number" && Number.isFinite(disappearDelaySeconds) ? disappearDelaySeconds : DEFAULT_CAMERA_RECORD.settings.disappearDelaySeconds
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the persisted blob.
|
|
85
|
+
*
|
|
86
|
+
* A malformed ENTRY falls back to the defaults and keeps its `enabled` flag —
|
|
87
|
+
* it is never dropped. Dropping it would disarm a function the operator armed,
|
|
88
|
+
* which is the one direction this must not fail in; a camera that is not in
|
|
89
|
+
* the blob at all is simply a camera nobody has switched on.
|
|
90
|
+
*/
|
|
91
|
+
function parseCameraRecords(raw) {
|
|
92
|
+
if (!isRecordObject(raw)) return {};
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
95
|
+
const deviceId = Number(key);
|
|
96
|
+
if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
|
|
97
|
+
if (!isRecordObject(value)) continue;
|
|
98
|
+
out[deviceId] = {
|
|
99
|
+
enabled: value["enabled"] === true,
|
|
100
|
+
settings: parseSettings(value["settings"])
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/** Immutable per-camera patch — a NEW map, the previous one untouched. */
|
|
106
|
+
function withCameraPatch(records, deviceId, patch) {
|
|
107
|
+
const current = records[deviceId] ?? DEFAULT_CAMERA_RECORD;
|
|
108
|
+
return {
|
|
109
|
+
...records,
|
|
110
|
+
[deviceId]: {
|
|
111
|
+
enabled: patch.enabled ?? current.enabled,
|
|
112
|
+
settings: {
|
|
113
|
+
...current.settings,
|
|
114
|
+
...patch.settings
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The detection macro class this camera follows.
|
|
121
|
+
*
|
|
122
|
+
* `''` is the cap's "camera default" and means PERSON here — the class an
|
|
123
|
+
* autotrack is asked for in almost every case, and the one the measurement
|
|
124
|
+
* slice used. A vendor string (`people`, `human`) normalises to the macro it
|
|
125
|
+
* names; anything else falls back to person rather than following nothing,
|
|
126
|
+
* because a loop that silently follows nothing is indistinguishable from one
|
|
127
|
+
* that is broken.
|
|
128
|
+
*/
|
|
129
|
+
function targetClassFor(settings) {
|
|
130
|
+
const raw = settings.targetType.trim().toLowerCase();
|
|
131
|
+
if (raw === "") return "person";
|
|
132
|
+
for (const macro of require_dist.FIRST_LEVEL_MACRO_CLASSES) if (raw === macro) return macro;
|
|
133
|
+
if (raw === "people" || raw === "human" || raw === "humans") return "person";
|
|
134
|
+
if (raw === "vehicles" || raw === "car" || raw === "motor_vehicle") return "vehicle";
|
|
135
|
+
if (raw === "pet" || raw === "pets" || raw === "dog" || raw === "cat") return "animal";
|
|
136
|
+
return "person";
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The loop's config for this camera.
|
|
140
|
+
*
|
|
141
|
+
* Only ONE of the cap's three settings reaches the loop:
|
|
142
|
+
*
|
|
143
|
+
* - `disappearDelaySeconds` → {@link AutotrackLoopConfig.disappearDelayMs}. Same
|
|
144
|
+
* meaning on both sides: how long a target that stopped appearing is held
|
|
145
|
+
* before it is released and the head goes home.
|
|
146
|
+
* - `targetType` → the detection class, through {@link targetClassFor}.
|
|
147
|
+
* - `stopDelaySeconds` is persisted and NOT honoured. It means "keep tracking a
|
|
148
|
+
* STATIONARY target for this long before stopping", and a pulse-based loop
|
|
149
|
+
* already does nothing while the subject is inside the dead zone — there is
|
|
150
|
+
* no motion to stop. Storing it and ignoring it is deliberate: the cap is
|
|
151
|
+
* cross-vendor, a native provider honours it, and dropping it from the
|
|
152
|
+
* payload would make a camera that switches from native to emulated lose a
|
|
153
|
+
* setting it had. It is reported back by `getStatus` verbatim, never as a
|
|
154
|
+
* number this loop acted on.
|
|
155
|
+
*
|
|
156
|
+
* Everything else in the loop config is a TUNING CONSTANT that D520 kept out of
|
|
157
|
+
* the operator surface on purpose (dead zone, settle zone, command ceiling,
|
|
158
|
+
* pulse dead time). They stay constants.
|
|
159
|
+
*/
|
|
160
|
+
function loopConfigFor(settings) {
|
|
161
|
+
return {
|
|
162
|
+
...DEFAULT_AUTOTRACK_LOOP_CONFIG,
|
|
163
|
+
disappearDelayMs: Math.max(0, Math.round(settings.disappearDelaySeconds * 1e3))
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** The cap's status for one camera. */
|
|
167
|
+
function statusFor(record, lastChangedAt) {
|
|
168
|
+
return {
|
|
169
|
+
enabled: record.enabled,
|
|
170
|
+
lastChangedAt,
|
|
171
|
+
currentSettings: record.settings,
|
|
172
|
+
supportedTargetTypes: EMULATED_TARGET_OPTIONS
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/builtins/autotrack/autotrack-decision.ts
|
|
177
|
+
var INITIAL_AUTOTRACK_LOOP_STATE = {
|
|
178
|
+
correctingX: false,
|
|
179
|
+
correctingY: false,
|
|
180
|
+
lastCommandAtMs: null,
|
|
181
|
+
homeIssued: false
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* One axis of the hysteresis.
|
|
185
|
+
*
|
|
186
|
+
* Outside the dead zone a correction STARTS; once started it continues until
|
|
187
|
+
* the subject is inside the tighter settle zone, and then it stops — short of
|
|
188
|
+
* the centre, on purpose. Aiming at the centre with an estimated gain
|
|
189
|
+
* overshoots, and an overshoot reverses the sign on the next frame.
|
|
190
|
+
*/
|
|
191
|
+
function stepAxis(error, correcting, config) {
|
|
192
|
+
const deadHalf = config.deadZoneFraction / 2;
|
|
193
|
+
const settleHalf = config.settleZoneFraction / 2;
|
|
194
|
+
const magnitude = Math.abs(error);
|
|
195
|
+
if (!(correcting ? magnitude > settleHalf : magnitude > deadHalf)) return {
|
|
196
|
+
step: 0,
|
|
197
|
+
correcting: false
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
step: error > 0 ? 1 : -1,
|
|
201
|
+
correcting: true
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function decideAutotrack(state, input) {
|
|
205
|
+
const hold = (reason, next) => ({
|
|
206
|
+
state: next,
|
|
207
|
+
decision: {
|
|
208
|
+
action: "hold",
|
|
209
|
+
reason
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
if (!input.armed) return hold("disarmed", state);
|
|
213
|
+
if (input.target === null) {
|
|
214
|
+
const idle = {
|
|
215
|
+
...state,
|
|
216
|
+
correctingX: false,
|
|
217
|
+
correctingY: false
|
|
218
|
+
};
|
|
219
|
+
if (input.targetLastSeenAtMs === null) return hold("no-target", idle);
|
|
220
|
+
if (input.nowMs - input.targetLastSeenAtMs < input.config.disappearDelayMs) return hold("target-absent", idle);
|
|
221
|
+
if (state.homeIssued) return hold("home-already-issued", idle);
|
|
222
|
+
return {
|
|
223
|
+
state: {
|
|
224
|
+
...idle,
|
|
225
|
+
homeIssued: true,
|
|
226
|
+
lastCommandAtMs: input.nowMs
|
|
227
|
+
},
|
|
228
|
+
decision: {
|
|
229
|
+
action: "home",
|
|
230
|
+
reason: "target-gone"
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const present = {
|
|
235
|
+
...state,
|
|
236
|
+
homeIssued: false
|
|
237
|
+
};
|
|
238
|
+
if (input.target.error === null) return hold("frame-geometry-unknown", present);
|
|
239
|
+
const x = stepAxis(input.target.error.x, state.correctingX, input.config);
|
|
240
|
+
const y = stepAxis(input.target.error.y, state.correctingY, input.config);
|
|
241
|
+
const tiltStep = y.step === 0 ? 0 : y.step > 0 ? -1 : 1;
|
|
242
|
+
const aimed = {
|
|
243
|
+
...present,
|
|
244
|
+
correctingX: x.correcting,
|
|
245
|
+
correctingY: y.correcting
|
|
246
|
+
};
|
|
247
|
+
if (x.step === 0 && y.step === 0) return hold(state.correctingX || state.correctingY ? "settled" : "inside-dead-zone", aimed);
|
|
248
|
+
if (input.nativeAutotrack === "enabled") return hold("native-autotrack-enabled", aimed);
|
|
249
|
+
if (input.nativeAutotrack === "unknown") return hold("native-autotrack-unknown", aimed);
|
|
250
|
+
const impulseMs = input.moveImpulseMs;
|
|
251
|
+
if (impulseMs === null) return hold("impulse-duration-unknown", aimed);
|
|
252
|
+
const lastCommandAtMs = state.lastCommandAtMs;
|
|
253
|
+
if (lastCommandAtMs !== null) {
|
|
254
|
+
const sinceMs = input.nowMs - lastCommandAtMs;
|
|
255
|
+
if (sinceMs < impulseMs + input.config.pulseSettleMs) return hold("pulse-dead-time", aimed);
|
|
256
|
+
if (sinceMs < 1e3 / input.config.maxCommandsPerSecond) return hold("rate-limited", aimed);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
state: {
|
|
260
|
+
...aimed,
|
|
261
|
+
lastCommandAtMs: input.nowMs
|
|
262
|
+
},
|
|
263
|
+
decision: {
|
|
264
|
+
action: "pulse",
|
|
265
|
+
pan: x.step,
|
|
266
|
+
tilt: tiltStep,
|
|
267
|
+
speed: input.config.speed
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/builtins/autotrack/frame-error.ts
|
|
273
|
+
function isPositiveFinite(value) {
|
|
274
|
+
return Number.isFinite(value) && value > 0;
|
|
275
|
+
}
|
|
276
|
+
function isFiniteBox(box) {
|
|
277
|
+
return Number.isFinite(box.x) && Number.isFinite(box.y) && Number.isFinite(box.width) && Number.isFinite(box.height);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Error of one subject box against the frame centre.
|
|
281
|
+
*
|
|
282
|
+
* `null` when the frame geometry or the box is unreadable — never a centred
|
|
283
|
+
* zero (D393).
|
|
284
|
+
*/
|
|
285
|
+
function computeNormalizedError(box, geometry) {
|
|
286
|
+
if (!isPositiveFinite(geometry.frameWidth) || !isPositiveFinite(geometry.frameHeight)) return null;
|
|
287
|
+
if (!isFiniteBox(box)) return null;
|
|
288
|
+
const centreX = box.x + box.width / 2;
|
|
289
|
+
const centreY = box.y + box.height / 2;
|
|
290
|
+
return {
|
|
291
|
+
x: centreX / geometry.frameWidth - .5,
|
|
292
|
+
y: centreY / geometry.frameHeight - .5
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/builtins/autotrack/frame-measurements.ts
|
|
297
|
+
var LATENCY_BUCKET_BOUNDS_MS = [
|
|
298
|
+
50,
|
|
299
|
+
100,
|
|
300
|
+
200,
|
|
301
|
+
400,
|
|
302
|
+
800,
|
|
303
|
+
1600,
|
|
304
|
+
3200
|
|
305
|
+
];
|
|
306
|
+
var INITIAL_LATENCY_TALLY = {
|
|
307
|
+
frames: 0,
|
|
308
|
+
sumMs: 0,
|
|
309
|
+
minMs: null,
|
|
310
|
+
maxMs: null,
|
|
311
|
+
bucketBoundsMs: LATENCY_BUCKET_BOUNDS_MS,
|
|
312
|
+
bucketCounts: LATENCY_BUCKET_BOUNDS_MS.map(() => 0).concat(0),
|
|
313
|
+
negativeAgeFrames: 0
|
|
314
|
+
};
|
|
315
|
+
function recordLatency(tally, latencyMs) {
|
|
316
|
+
if (!Number.isFinite(latencyMs)) return tally;
|
|
317
|
+
let bucket = tally.bucketBoundsMs.findIndex((bound) => latencyMs <= bound);
|
|
318
|
+
if (bucket < 0) bucket = tally.bucketBoundsMs.length;
|
|
319
|
+
const bucketCounts = tally.bucketCounts.map((count, i) => i === bucket ? count + 1 : count);
|
|
320
|
+
return {
|
|
321
|
+
frames: tally.frames + 1,
|
|
322
|
+
sumMs: tally.sumMs + latencyMs,
|
|
323
|
+
minMs: tally.minMs === null ? latencyMs : Math.min(tally.minMs, latencyMs),
|
|
324
|
+
maxMs: tally.maxMs === null ? latencyMs : Math.max(tally.maxMs, latencyMs),
|
|
325
|
+
bucketBoundsMs: tally.bucketBoundsMs,
|
|
326
|
+
bucketCounts,
|
|
327
|
+
negativeAgeFrames: tally.negativeAgeFrames + (latencyMs < 0 ? 1 : 0)
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
var INITIAL_PRESENCE_TALLY = {
|
|
331
|
+
framesWithTarget: 0,
|
|
332
|
+
absentFrames: 0,
|
|
333
|
+
currentGapFrames: 0,
|
|
334
|
+
maxGapFrames: 0,
|
|
335
|
+
gapRunHistogram: Array.from({ length: 9 }, () => 0)
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Count one frame on which a target was held.
|
|
339
|
+
*
|
|
340
|
+
* `present` is "the held track carried a box on THIS frame". A closing gap is
|
|
341
|
+
* binned at the moment it closes, which is why the open gap is reported
|
|
342
|
+
* separately (`currentGapFrames`) instead of being counted twice.
|
|
343
|
+
*/
|
|
344
|
+
function recordPresence(tally, present) {
|
|
345
|
+
if (present) {
|
|
346
|
+
if (tally.currentGapFrames === 0) return {
|
|
347
|
+
...tally,
|
|
348
|
+
framesWithTarget: tally.framesWithTarget + 1
|
|
349
|
+
};
|
|
350
|
+
const slot = Math.min(tally.currentGapFrames, 8) - 1;
|
|
351
|
+
const index = tally.currentGapFrames > 8 ? 8 : slot;
|
|
352
|
+
return {
|
|
353
|
+
framesWithTarget: tally.framesWithTarget + 1,
|
|
354
|
+
absentFrames: tally.absentFrames,
|
|
355
|
+
currentGapFrames: 0,
|
|
356
|
+
maxGapFrames: tally.maxGapFrames,
|
|
357
|
+
gapRunHistogram: tally.gapRunHistogram.map((n, i) => i === index ? n + 1 : n)
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const currentGapFrames = tally.currentGapFrames + 1;
|
|
361
|
+
return {
|
|
362
|
+
framesWithTarget: tally.framesWithTarget + 1,
|
|
363
|
+
absentFrames: tally.absentFrames + 1,
|
|
364
|
+
currentGapFrames,
|
|
365
|
+
maxGapFrames: Math.max(tally.maxGapFrames, currentGapFrames),
|
|
366
|
+
gapRunHistogram: tally.gapRunHistogram
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function summarize(latency, presence) {
|
|
370
|
+
return {
|
|
371
|
+
frames: latency.frames,
|
|
372
|
+
latencyMeanMs: latency.frames === 0 ? null : latency.sumMs / latency.frames,
|
|
373
|
+
latencyMinMs: latency.minMs,
|
|
374
|
+
latencyMaxMs: latency.maxMs,
|
|
375
|
+
latencyBucketsMs: latency.bucketBoundsMs,
|
|
376
|
+
latencyBucketCounts: latency.bucketCounts,
|
|
377
|
+
negativeAgeFrames: latency.negativeAgeFrames,
|
|
378
|
+
framesWithTarget: presence.framesWithTarget,
|
|
379
|
+
absentFrames: presence.absentFrames,
|
|
380
|
+
absentFraction: presence.framesWithTarget === 0 ? null : presence.absentFrames / presence.framesWithTarget,
|
|
381
|
+
maxGapFrames: presence.maxGapFrames,
|
|
382
|
+
openGapFrames: presence.currentGapFrames,
|
|
383
|
+
gapRunHistogram: presence.gapRunHistogram
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/builtins/autotrack/ptz-mirror.ts
|
|
388
|
+
var INITIAL_PTZ_MIRROR = {
|
|
389
|
+
moveImpulseMs: null,
|
|
390
|
+
nativeAutotrack: "unknown",
|
|
391
|
+
lastRefreshAtMs: null,
|
|
392
|
+
lastError: null,
|
|
393
|
+
consecutiveFailures: 0
|
|
394
|
+
};
|
|
395
|
+
function applyMirrorRefresh(mirror, outcome, nowMs) {
|
|
396
|
+
if (outcome.kind === "failed") return {
|
|
397
|
+
...mirror,
|
|
398
|
+
lastError: outcome.error,
|
|
399
|
+
consecutiveFailures: mirror.consecutiveFailures + 1
|
|
400
|
+
};
|
|
401
|
+
return {
|
|
402
|
+
moveImpulseMs: outcome.moveImpulseMs ?? mirror.moveImpulseMs,
|
|
403
|
+
nativeAutotrack: outcome.nativeAutotrack,
|
|
404
|
+
lastRefreshAtMs: nowMs,
|
|
405
|
+
lastError: null,
|
|
406
|
+
consecutiveFailures: 0
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/builtins/autotrack/target-selection.ts
|
|
411
|
+
var INITIAL_TARGET_SELECTION_STATE = {
|
|
412
|
+
trackId: null,
|
|
413
|
+
lastSeenAtMs: null
|
|
414
|
+
};
|
|
415
|
+
/** Largest box first; `trackId` breaks a tie so the choice is deterministic
|
|
416
|
+
* rather than dependent on detector ordering. */
|
|
417
|
+
function pickFirst(candidates) {
|
|
418
|
+
let best = null;
|
|
419
|
+
for (const c of candidates) {
|
|
420
|
+
if (best === null || c.areaPx > best.areaPx) {
|
|
421
|
+
best = c;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (c.areaPx === best.areaPx && c.trackId < best.trackId) best = c;
|
|
425
|
+
}
|
|
426
|
+
return best;
|
|
427
|
+
}
|
|
428
|
+
function selectTarget(state, input) {
|
|
429
|
+
const held = state.trackId;
|
|
430
|
+
if (held !== null) {
|
|
431
|
+
if (input.candidates.some((c) => c.trackId === held)) return {
|
|
432
|
+
state: {
|
|
433
|
+
trackId: held,
|
|
434
|
+
lastSeenAtMs: input.nowMs
|
|
435
|
+
},
|
|
436
|
+
trackId: held,
|
|
437
|
+
present: true,
|
|
438
|
+
reason: "held"
|
|
439
|
+
};
|
|
440
|
+
const lastSeen = state.lastSeenAtMs;
|
|
441
|
+
if ((lastSeen === null ? Number.POSITIVE_INFINITY : input.nowMs - lastSeen) < input.disappearDelayMs) return {
|
|
442
|
+
state,
|
|
443
|
+
trackId: held,
|
|
444
|
+
present: false,
|
|
445
|
+
reason: "held-absent"
|
|
446
|
+
};
|
|
447
|
+
const replacement = pickFirst(input.candidates);
|
|
448
|
+
if (replacement === null) return {
|
|
449
|
+
state: INITIAL_TARGET_SELECTION_STATE,
|
|
450
|
+
trackId: null,
|
|
451
|
+
present: false,
|
|
452
|
+
reason: "released"
|
|
453
|
+
};
|
|
454
|
+
return {
|
|
455
|
+
state: {
|
|
456
|
+
trackId: replacement.trackId,
|
|
457
|
+
lastSeenAtMs: input.nowMs
|
|
458
|
+
},
|
|
459
|
+
trackId: replacement.trackId,
|
|
460
|
+
present: true,
|
|
461
|
+
reason: "switched"
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const picked = pickFirst(input.candidates);
|
|
465
|
+
if (picked === null) return {
|
|
466
|
+
state: INITIAL_TARGET_SELECTION_STATE,
|
|
467
|
+
trackId: null,
|
|
468
|
+
present: false,
|
|
469
|
+
reason: "idle"
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
state: {
|
|
473
|
+
trackId: picked.trackId,
|
|
474
|
+
lastSeenAtMs: input.nowMs
|
|
475
|
+
},
|
|
476
|
+
trackId: picked.trackId,
|
|
477
|
+
present: true,
|
|
478
|
+
reason: "acquired"
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/builtins/autotrack/autotrack-loop.ts
|
|
483
|
+
/**
|
|
484
|
+
* Candidates of the configured class on this frame.
|
|
485
|
+
*
|
|
486
|
+
* A detection counts only when it carries a `track` — `buildOverlayDetections`
|
|
487
|
+
* attaches one exactly to the boxes a tracked entry was ASSOCIATED with on this
|
|
488
|
+
* frame, so this set is the observed-and-tracked set and nothing else.
|
|
489
|
+
*/
|
|
490
|
+
function candidatesFromFrame(detections, targetClass) {
|
|
491
|
+
const out = [];
|
|
492
|
+
for (const det of detections) {
|
|
493
|
+
if (det.kind !== "first-level") continue;
|
|
494
|
+
if (det.macroClass !== targetClass) continue;
|
|
495
|
+
const trackId = det.track?.trackId;
|
|
496
|
+
if (trackId === void 0) continue;
|
|
497
|
+
out.push({
|
|
498
|
+
trackId,
|
|
499
|
+
areaPx: Math.max(0, det.bbox.width) * Math.max(0, det.bbox.height)
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
var AutotrackLoop = class {
|
|
505
|
+
deps;
|
|
506
|
+
selection = INITIAL_TARGET_SELECTION_STATE;
|
|
507
|
+
loopState = INITIAL_AUTOTRACK_LOOP_STATE;
|
|
508
|
+
mirror = INITIAL_PTZ_MIRROR;
|
|
509
|
+
latency = INITIAL_LATENCY_TALLY;
|
|
510
|
+
presence = INITIAL_PRESENCE_TALLY;
|
|
511
|
+
lastFrameAtMs = null;
|
|
512
|
+
lastSummaryAtMs = null;
|
|
513
|
+
constructor(deps) {
|
|
514
|
+
this.deps = deps;
|
|
515
|
+
}
|
|
516
|
+
/** Read-only view of the mirror, for the addon's startup log. */
|
|
517
|
+
get ptzMirror() {
|
|
518
|
+
return this.mirror;
|
|
519
|
+
}
|
|
520
|
+
/** Fold one refresh outcome into the mirror. Called by the TIMER, never from
|
|
521
|
+
* {@link onFrame}. */
|
|
522
|
+
applyMirror(outcome) {
|
|
523
|
+
const nowMs = this.deps.now();
|
|
524
|
+
this.mirror = applyMirrorRefresh(this.mirror, outcome, nowMs);
|
|
525
|
+
if (outcome.kind === "failed") {
|
|
526
|
+
this.deps.logger.warn("autotrack mirror refresh failed", {
|
|
527
|
+
tags: { deviceId: this.deps.deviceId },
|
|
528
|
+
meta: {
|
|
529
|
+
error: outcome.error,
|
|
530
|
+
consecutiveFailures: this.mirror.consecutiveFailures,
|
|
531
|
+
moveImpulseMs: this.mirror.moveImpulseMs,
|
|
532
|
+
nativeAutotrack: this.mirror.nativeAutotrack
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
this.deps.logger.debug("autotrack mirror refreshed", {
|
|
538
|
+
tags: { deviceId: this.deps.deviceId },
|
|
539
|
+
meta: {
|
|
540
|
+
moveImpulseMs: this.mirror.moveImpulseMs,
|
|
541
|
+
nativeAutotrack: this.mirror.nativeAutotrack
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/** One analysed frame. Measures first, then decides. */
|
|
546
|
+
async onFrame(frame) {
|
|
547
|
+
if (frame.deviceId !== this.deps.deviceId) return;
|
|
548
|
+
const nowMs = this.deps.now();
|
|
549
|
+
this.lastFrameAtMs = nowMs;
|
|
550
|
+
const busLatencyMs = nowMs - frame.timestamp;
|
|
551
|
+
this.latency = recordLatency(this.latency, busLatencyMs);
|
|
552
|
+
const candidates = candidatesFromFrame(frame.detections, this.deps.targetClass);
|
|
553
|
+
const selected = selectTarget(this.selection, {
|
|
554
|
+
nowMs,
|
|
555
|
+
candidates,
|
|
556
|
+
disappearDelayMs: this.deps.config.disappearDelayMs
|
|
557
|
+
});
|
|
558
|
+
this.selection = selected.state;
|
|
559
|
+
if (selected.trackId !== null) this.presence = recordPresence(this.presence, selected.present);
|
|
560
|
+
let error = null;
|
|
561
|
+
if (selected.present && selected.trackId !== null) {
|
|
562
|
+
const det = frame.detections.find((d) => d.track?.trackId === selected.trackId);
|
|
563
|
+
if (det !== void 0) error = computeNormalizedError(det.bbox, {
|
|
564
|
+
frameWidth: frame.frameWidth,
|
|
565
|
+
frameHeight: frame.frameHeight
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
const result = decideAutotrack(this.loopState, {
|
|
569
|
+
nowMs,
|
|
570
|
+
armed: this.deps.armed,
|
|
571
|
+
target: selected.present && selected.trackId !== null ? {
|
|
572
|
+
trackId: selected.trackId,
|
|
573
|
+
error
|
|
574
|
+
} : null,
|
|
575
|
+
targetLastSeenAtMs: this.selection.lastSeenAtMs,
|
|
576
|
+
moveImpulseMs: this.mirror.moveImpulseMs,
|
|
577
|
+
nativeAutotrack: this.mirror.nativeAutotrack,
|
|
578
|
+
config: this.deps.config
|
|
579
|
+
});
|
|
580
|
+
this.loopState = result.state;
|
|
581
|
+
this.logDecision(result.decision, {
|
|
582
|
+
busLatencyMs,
|
|
583
|
+
candidates: candidates.length,
|
|
584
|
+
trackId: selected.trackId,
|
|
585
|
+
present: selected.present,
|
|
586
|
+
selection: selected.reason,
|
|
587
|
+
error,
|
|
588
|
+
source: "frame"
|
|
589
|
+
});
|
|
590
|
+
await this.dispatch(result.decision);
|
|
591
|
+
this.maybeSummarize(nowMs);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Off-event-path tick: the silence watchdog.
|
|
595
|
+
*
|
|
596
|
+
* Evaluates the SAME disappear delay against the time since the last frame.
|
|
597
|
+
* A stream that died is a target that is gone, and the head goes home —
|
|
598
|
+
* which is also the only thing that would otherwise never happen, because
|
|
599
|
+
* every other release is driven by a frame arriving.
|
|
600
|
+
*/
|
|
601
|
+
async tick() {
|
|
602
|
+
const nowMs = this.deps.now();
|
|
603
|
+
const lastFrameAtMs = this.lastFrameAtMs;
|
|
604
|
+
if (lastFrameAtMs === null) return;
|
|
605
|
+
const silentForMs = nowMs - lastFrameAtMs;
|
|
606
|
+
if (silentForMs < this.deps.config.disappearDelayMs) return;
|
|
607
|
+
const heldTrackId = this.selection.trackId;
|
|
608
|
+
if (heldTrackId === null) return;
|
|
609
|
+
this.selection = INITIAL_TARGET_SELECTION_STATE;
|
|
610
|
+
const result = decideAutotrack(this.loopState, {
|
|
611
|
+
nowMs,
|
|
612
|
+
armed: this.deps.armed,
|
|
613
|
+
target: null,
|
|
614
|
+
targetLastSeenAtMs: lastFrameAtMs,
|
|
615
|
+
moveImpulseMs: this.mirror.moveImpulseMs,
|
|
616
|
+
nativeAutotrack: this.mirror.nativeAutotrack,
|
|
617
|
+
config: this.deps.config
|
|
618
|
+
});
|
|
619
|
+
this.loopState = result.state;
|
|
620
|
+
this.deps.logger.warn("autotrack frame silence", {
|
|
621
|
+
tags: { deviceId: this.deps.deviceId },
|
|
622
|
+
meta: {
|
|
623
|
+
silentForMs,
|
|
624
|
+
disappearDelayMs: this.deps.config.disappearDelayMs,
|
|
625
|
+
heldTrackId
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
this.logDecision(result.decision, {
|
|
629
|
+
busLatencyMs: null,
|
|
630
|
+
candidates: 0,
|
|
631
|
+
trackId: heldTrackId,
|
|
632
|
+
present: false,
|
|
633
|
+
selection: "silence-released",
|
|
634
|
+
error: null,
|
|
635
|
+
source: "tick"
|
|
636
|
+
});
|
|
637
|
+
await this.dispatch(result.decision);
|
|
638
|
+
}
|
|
639
|
+
/** Emit the aggregates now, whatever the schedule says. Used at shutdown so
|
|
640
|
+
* a short run still reports what it measured. */
|
|
641
|
+
logSummary() {
|
|
642
|
+
const meta = { ...summarize(this.latency, this.presence) };
|
|
643
|
+
this.deps.logger.info("autotrack measurements", {
|
|
644
|
+
tags: { deviceId: this.deps.deviceId },
|
|
645
|
+
meta
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
maybeSummarize(nowMs) {
|
|
649
|
+
const last = this.lastSummaryAtMs;
|
|
650
|
+
if (last !== null && nowMs - last < this.deps.config.measurementSummaryMs) return;
|
|
651
|
+
this.lastSummaryAtMs = nowMs;
|
|
652
|
+
this.logSummary();
|
|
653
|
+
}
|
|
654
|
+
async dispatch(decision) {
|
|
655
|
+
if (decision.action === "hold") return;
|
|
656
|
+
try {
|
|
657
|
+
if (decision.action === "home") {
|
|
658
|
+
await this.deps.commands.goHome(this.deps.deviceId);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
await this.deps.commands.pulse({
|
|
662
|
+
deviceId: this.deps.deviceId,
|
|
663
|
+
pan: decision.pan,
|
|
664
|
+
tilt: decision.tilt,
|
|
665
|
+
speed: decision.speed
|
|
666
|
+
});
|
|
667
|
+
} catch (err) {
|
|
668
|
+
this.deps.logger.warn("autotrack command failed", {
|
|
669
|
+
tags: { deviceId: this.deps.deviceId },
|
|
670
|
+
meta: {
|
|
671
|
+
action: decision.action,
|
|
672
|
+
error: err instanceof Error ? err.message : String(err)
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
logDecision(decision, context) {
|
|
678
|
+
const meta = {
|
|
679
|
+
source: context.source,
|
|
680
|
+
busLatencyMs: context.busLatencyMs,
|
|
681
|
+
candidates: context.candidates,
|
|
682
|
+
trackId: context.trackId,
|
|
683
|
+
trackPresent: context.present,
|
|
684
|
+
selection: context.selection,
|
|
685
|
+
errorX: context.error === null ? null : round3(context.error.x),
|
|
686
|
+
errorY: context.error === null ? null : round3(context.error.y),
|
|
687
|
+
action: decision.action,
|
|
688
|
+
reason: decision.action === "pulse" ? null : decision.reason,
|
|
689
|
+
pan: decision.action === "pulse" ? decision.pan : null,
|
|
690
|
+
tilt: decision.action === "pulse" ? decision.tilt : null,
|
|
691
|
+
moveImpulseMs: this.mirror.moveImpulseMs,
|
|
692
|
+
nativeAutotrack: this.mirror.nativeAutotrack
|
|
693
|
+
};
|
|
694
|
+
if (decision.action === "hold") {
|
|
695
|
+
this.deps.logger.debug("autotrack hold", {
|
|
696
|
+
tags: { deviceId: this.deps.deviceId },
|
|
697
|
+
meta
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
this.deps.logger.info("autotrack command", {
|
|
702
|
+
tags: { deviceId: this.deps.deviceId },
|
|
703
|
+
meta
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
function round3(value) {
|
|
708
|
+
return Math.round(value * 1e3) / 1e3;
|
|
709
|
+
}
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region src/builtins/autotrack/autotrack.addon.ts
|
|
712
|
+
/** How often the silence watchdog runs. Small relative to the disappear delay
|
|
713
|
+
* it enforces — the delay is the budget, this is only the resolution. */
|
|
714
|
+
var WATCHDOG_INTERVAL_MS = 5e3;
|
|
715
|
+
var AutotrackAddon = class extends require_dist.BaseAddon {
|
|
716
|
+
records = {};
|
|
717
|
+
sessions = /* @__PURE__ */ new Map();
|
|
718
|
+
/** Cameras whose `ptz-autotrack` binding names THIS addon, last time we read. */
|
|
719
|
+
bound = /* @__PURE__ */ new Set();
|
|
720
|
+
/**
|
|
721
|
+
* Cameras that ALSO have a `ptz-autotrack` provider that is not us.
|
|
722
|
+
*
|
|
723
|
+
* This is the only way to ask about a firmware autotrack without asking
|
|
724
|
+
* ourselves: on a camera where our wrapper is the binding, `ptzAutotrack.
|
|
725
|
+
* getStatus` resolves to THIS addon and returns our own switch — so a loop
|
|
726
|
+
* that read it would see `enabled: true` the moment the operator turned it
|
|
727
|
+
* on, conclude the firmware was driving the head, and refuse to move. A
|
|
728
|
+
* self-deadlock that would have looked exactly like the safety gate working.
|
|
729
|
+
*/
|
|
730
|
+
foreignProvider = /* @__PURE__ */ new Set();
|
|
731
|
+
lastChangedAt = /* @__PURE__ */ new Map();
|
|
732
|
+
unsubscribeFrames = null;
|
|
733
|
+
unsubscribeBindings = null;
|
|
734
|
+
mirrorTimer = null;
|
|
735
|
+
watchdogTimer = null;
|
|
736
|
+
constructor() {
|
|
737
|
+
super({ cameras: {} });
|
|
738
|
+
}
|
|
739
|
+
async onInitialize() {
|
|
740
|
+
this.records = parseCameraRecords(this.config.cameras);
|
|
741
|
+
this.unsubscribeFrames = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsFrameTracked }, (event) => {
|
|
742
|
+
if (!require_dist.isEvent(event, require_dist.EventCategory.PipelineAnalyticsFrameTracked)) return;
|
|
743
|
+
const session = this.sessions.get(event.data.deviceId);
|
|
744
|
+
if (session === void 0) return;
|
|
745
|
+
session.loop.onFrame({
|
|
746
|
+
deviceId: event.data.deviceId,
|
|
747
|
+
timestamp: event.data.timestamp,
|
|
748
|
+
frameWidth: event.data.frameWidth,
|
|
749
|
+
frameHeight: event.data.frameHeight,
|
|
750
|
+
detections: event.data.detections
|
|
751
|
+
}).catch((err) => {
|
|
752
|
+
this.ctx.logger.warn("autotrack frame handling failed", {
|
|
753
|
+
tags: { deviceId: event.data.deviceId },
|
|
754
|
+
meta: { error: require_dist.errMsg(err) }
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
this.unsubscribeBindings = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, () => {
|
|
759
|
+
this.refreshBindings().catch(() => void 0);
|
|
760
|
+
});
|
|
761
|
+
this.mirrorTimer = setInterval(() => {
|
|
762
|
+
this.refreshEverything();
|
|
763
|
+
}, DEFAULT_AUTOTRACK_LOOP_CONFIG.mirrorRefreshMs);
|
|
764
|
+
this.watchdogTimer = setInterval(() => {
|
|
765
|
+
for (const session of this.sessions.values()) session.loop.tick().catch((err) => {
|
|
766
|
+
this.ctx.logger.warn("autotrack watchdog failed", {
|
|
767
|
+
tags: { deviceId: session.deviceId },
|
|
768
|
+
meta: { error: require_dist.errMsg(err) }
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
772
|
+
await this.refreshEverything();
|
|
773
|
+
const provider = {
|
|
774
|
+
getStatus: async ({ deviceId }) => this.statusOf(deviceId),
|
|
775
|
+
setEnabled: async ({ deviceId, enabled }) => {
|
|
776
|
+
await this.patchCamera(deviceId, { enabled });
|
|
777
|
+
},
|
|
778
|
+
getSettings: async ({ deviceId }) => this.recordOf(deviceId).settings,
|
|
779
|
+
setSettings: async ({ deviceId, settings }) => {
|
|
780
|
+
await this.patchCamera(deviceId, { settings });
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
this.ctx.logger.info("autotrack ready", { meta: {
|
|
784
|
+
cameras: Object.keys(this.records).length,
|
|
785
|
+
running: this.sessions.size
|
|
786
|
+
} });
|
|
787
|
+
return { providers: [{
|
|
788
|
+
capability: require_dist.ptzAutotrackCapability,
|
|
789
|
+
provider
|
|
790
|
+
}] };
|
|
791
|
+
}
|
|
792
|
+
async onShutdown() {
|
|
793
|
+
if (this.mirrorTimer !== null) clearInterval(this.mirrorTimer);
|
|
794
|
+
if (this.watchdogTimer !== null) clearInterval(this.watchdogTimer);
|
|
795
|
+
this.mirrorTimer = null;
|
|
796
|
+
this.watchdogTimer = null;
|
|
797
|
+
this.unsubscribeFrames?.();
|
|
798
|
+
this.unsubscribeBindings?.();
|
|
799
|
+
this.unsubscribeFrames = null;
|
|
800
|
+
this.unsubscribeBindings = null;
|
|
801
|
+
for (const session of this.sessions.values()) session.loop.logSummary();
|
|
802
|
+
this.sessions.clear();
|
|
803
|
+
}
|
|
804
|
+
async onConfigChanged() {
|
|
805
|
+
this.records = parseCameraRecords(this.config.cameras);
|
|
806
|
+
await this.reconcileSessions();
|
|
807
|
+
}
|
|
808
|
+
recordOf(deviceId) {
|
|
809
|
+
return this.records[deviceId] ?? DEFAULT_CAMERA_RECORD;
|
|
810
|
+
}
|
|
811
|
+
statusOf(deviceId) {
|
|
812
|
+
return statusFor(this.recordOf(deviceId), this.lastChangedAt.get(deviceId) ?? 0);
|
|
813
|
+
}
|
|
814
|
+
async patchCamera(deviceId, patch) {
|
|
815
|
+
const before = this.recordOf(deviceId).enabled;
|
|
816
|
+
this.records = withCameraPatch(this.records, deviceId, patch);
|
|
817
|
+
if (before !== this.recordOf(deviceId).enabled) this.lastChangedAt.set(deviceId, Date.now());
|
|
818
|
+
await this.updateGlobalSettings({ cameras: this.serialiseRecords() });
|
|
819
|
+
await this.reconcileSessions();
|
|
820
|
+
}
|
|
821
|
+
serialiseRecords() {
|
|
822
|
+
const out = {};
|
|
823
|
+
for (const [deviceId, record] of Object.entries(this.records)) out[deviceId] = {
|
|
824
|
+
enabled: record.enabled,
|
|
825
|
+
settings: record.settings
|
|
826
|
+
};
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
/** Bindings first, then sessions: a session must never outlive its binding. */
|
|
830
|
+
async refreshEverything() {
|
|
831
|
+
await this.refreshBindings();
|
|
832
|
+
await this.reconcileSessions();
|
|
833
|
+
for (const session of this.sessions.values()) await this.refreshMirror(session);
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Which cameras have this addon bound as their `ptz-autotrack` provider.
|
|
837
|
+
*
|
|
838
|
+
* Only cameras the operator has a record for are asked about: a wrapper that
|
|
839
|
+
* enumerated every camera on every refresh would walk the whole fleet to
|
|
840
|
+
* learn nothing about the ones nobody switched on.
|
|
841
|
+
*
|
|
842
|
+
* A read that FAILS leaves the previous answer standing (D49) — a transient
|
|
843
|
+
* store blip must not stop a running loop. A read that SUCCEEDS and omits us
|
|
844
|
+
* is believed immediately: unlike a doorbell press, the cost of erring open
|
|
845
|
+
* here is a head that keeps moving after the operator unbound it.
|
|
846
|
+
*/
|
|
847
|
+
async refreshBindings() {
|
|
848
|
+
const next = /* @__PURE__ */ new Set();
|
|
849
|
+
const foreign = new Set(this.foreignProvider);
|
|
850
|
+
const me = require_dist.bareAddonId(this.ctx.id);
|
|
851
|
+
for (const key of Object.keys(this.records)) {
|
|
852
|
+
const deviceId = Number(key);
|
|
853
|
+
if (!Number.isInteger(deviceId)) continue;
|
|
854
|
+
try {
|
|
855
|
+
const { entries } = await this.ctx.api.deviceManager.getBindings.query({ deviceId });
|
|
856
|
+
const autotrackEntries = entries.filter((entry) => entry.capName === require_dist.ptzAutotrackCapability.name);
|
|
857
|
+
if (autotrackEntries.some((entry) => require_dist.bareAddonId(entry.providerAddonId) === me)) next.add(deviceId);
|
|
858
|
+
if (autotrackEntries.some((entry) => require_dist.bareAddonId(entry.providerAddonId) !== me)) foreign.add(deviceId);
|
|
859
|
+
else foreign.delete(deviceId);
|
|
860
|
+
} catch (err) {
|
|
861
|
+
if (this.bound.has(deviceId)) next.add(deviceId);
|
|
862
|
+
this.ctx.logger.debug("autotrack: could not read bindings", {
|
|
863
|
+
tags: { deviceId },
|
|
864
|
+
meta: { error: require_dist.errMsg(err) }
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
this.bound = next;
|
|
869
|
+
this.foreignProvider = foreign;
|
|
870
|
+
}
|
|
871
|
+
/** Start a loop for every camera that is bound AND switched on; stop the rest. */
|
|
872
|
+
async reconcileSessions() {
|
|
873
|
+
for (const [key, record] of Object.entries(this.records)) {
|
|
874
|
+
const deviceId = Number(key);
|
|
875
|
+
const shouldRun = record.enabled && this.bound.has(deviceId);
|
|
876
|
+
const running = this.sessions.get(deviceId);
|
|
877
|
+
if (shouldRun && running === void 0) {
|
|
878
|
+
this.startSession(deviceId, record);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (!shouldRun && running !== void 0) this.stopSession(deviceId, record.enabled ? "unbound" : "switched-off");
|
|
882
|
+
}
|
|
883
|
+
for (const deviceId of this.sessions.keys()) if (this.records[deviceId] === void 0) this.stopSession(deviceId, "record-gone");
|
|
884
|
+
}
|
|
885
|
+
startSession(deviceId, record) {
|
|
886
|
+
const config = loopConfigFor(record.settings);
|
|
887
|
+
const targetClass = targetClassFor(record.settings);
|
|
888
|
+
const loop = new AutotrackLoop({
|
|
889
|
+
deviceId,
|
|
890
|
+
targetClass,
|
|
891
|
+
armed: true,
|
|
892
|
+
config,
|
|
893
|
+
logger: this.ctx.logger,
|
|
894
|
+
commands: {
|
|
895
|
+
pulse: (command) => this.ctx.api.ptz.move.mutate({
|
|
896
|
+
deviceId: command.deviceId,
|
|
897
|
+
pan: command.pan,
|
|
898
|
+
tilt: command.tilt,
|
|
899
|
+
speed: command.speed
|
|
900
|
+
}),
|
|
901
|
+
goHome: (target) => this.ctx.api.ptz.goHome.mutate({ deviceId: target })
|
|
902
|
+
},
|
|
903
|
+
now: () => Date.now()
|
|
904
|
+
});
|
|
905
|
+
this.sessions.set(deviceId, {
|
|
906
|
+
loop,
|
|
907
|
+
deviceId
|
|
908
|
+
});
|
|
909
|
+
this.refreshMirror({
|
|
910
|
+
loop,
|
|
911
|
+
deviceId
|
|
912
|
+
});
|
|
913
|
+
this.ctx.logger.info("autotrack following", {
|
|
914
|
+
tags: { deviceId },
|
|
915
|
+
meta: {
|
|
916
|
+
targetClass,
|
|
917
|
+
deadZoneFraction: config.deadZoneFraction,
|
|
918
|
+
settleZoneFraction: config.settleZoneFraction,
|
|
919
|
+
maxCommandsPerSecond: config.maxCommandsPerSecond,
|
|
920
|
+
disappearDelayMs: config.disappearDelayMs
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
this.ctx.logger.warn("autotrack ON: this camera must not be driven by anyone else", { tags: { deviceId } });
|
|
924
|
+
}
|
|
925
|
+
stopSession(deviceId, reason) {
|
|
926
|
+
const session = this.sessions.get(deviceId);
|
|
927
|
+
if (session === void 0) return;
|
|
928
|
+
session.loop.logSummary();
|
|
929
|
+
this.sessions.delete(deviceId);
|
|
930
|
+
this.ctx.logger.info("autotrack stopped following", {
|
|
931
|
+
tags: { deviceId },
|
|
932
|
+
meta: { reason }
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* One off-event-path refresh of a camera's mirror.
|
|
937
|
+
*
|
|
938
|
+
* Two reads, and either one failing fails the whole refresh: a mirror half
|
|
939
|
+
* from now and half from twenty minutes ago is worse than one that says it
|
|
940
|
+
* is stale. The previous answers stand (D49).
|
|
941
|
+
*/
|
|
942
|
+
async refreshMirror(session) {
|
|
943
|
+
try {
|
|
944
|
+
const options = await this.ctx.api.ptz.getOptions.query({ deviceId: session.deviceId });
|
|
945
|
+
const nativeAutotrack = await this.readNativeAutotrack(session.deviceId);
|
|
946
|
+
session.loop.applyMirror({
|
|
947
|
+
kind: "read",
|
|
948
|
+
moveImpulseMs: options.moveImpulseMs,
|
|
949
|
+
nativeAutotrack
|
|
950
|
+
});
|
|
951
|
+
} catch (err) {
|
|
952
|
+
session.loop.applyMirror({
|
|
953
|
+
kind: "failed",
|
|
954
|
+
error: require_dist.errMsg(err)
|
|
955
|
+
});
|
|
956
|
+
this.ctx.logger.warn("autotrack mirror refresh failed — the loop holds its last answers", {
|
|
957
|
+
tags: { deviceId: session.deviceId },
|
|
958
|
+
meta: { error: require_dist.errMsg(err) }
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Is the camera's OWN autotrack on?
|
|
964
|
+
*
|
|
965
|
+
* `null` when the camera has no native autotrack at all — which is the case
|
|
966
|
+
* this addon exists for, and is NOT an error. A camera that HAS one and has
|
|
967
|
+
* it armed makes this loop refuse: two implementations driving one head is
|
|
968
|
+
* the thing the single-switch rule exists to prevent (D62).
|
|
969
|
+
*/
|
|
970
|
+
async readNativeAutotrack(deviceId) {
|
|
971
|
+
if (!this.foreignProvider.has(deviceId)) return "absent";
|
|
972
|
+
try {
|
|
973
|
+
return (await this.ctx.api.ptzAutotrack.getStatus.query({ deviceId })).enabled ? "enabled" : "disabled";
|
|
974
|
+
} catch {
|
|
975
|
+
return "unknown";
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
//#endregion
|
|
980
|
+
exports.AutotrackAddon = AutotrackAddon;
|
|
981
|
+
exports.default = AutotrackAddon;
|