@camstack/system 1.2.254 → 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.
Files changed (88) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/autotrack-cameras.d.ts +76 -0
  8. package/dist/builtins/autotrack/autotrack-config.d.ts +89 -0
  9. package/dist/builtins/autotrack/autotrack-decision.d.ts +99 -0
  10. package/dist/builtins/autotrack/autotrack-loop.d.ts +75 -0
  11. package/dist/builtins/autotrack/autotrack.addon.d.ts +71 -0
  12. package/dist/builtins/autotrack/frame-error.d.ts +50 -0
  13. package/dist/builtins/autotrack/frame-measurements.d.ts +97 -0
  14. package/dist/builtins/autotrack/index.d.ts +2 -0
  15. package/dist/builtins/autotrack/index.js +981 -0
  16. package/dist/builtins/autotrack/index.mjs +975 -0
  17. package/dist/builtins/autotrack/ptz-mirror.d.ts +25 -0
  18. package/dist/builtins/autotrack/target-selection.d.ts +60 -0
  19. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  20. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  21. package/dist/builtins/camera-grid/addon.d.ts +118 -0
  22. package/dist/builtins/camera-grid/flv-join.d.ts +66 -0
  23. package/dist/builtins/camera-grid/grid-camera-declarations.d.ts +59 -0
  24. package/dist/builtins/camera-grid/grid-camera-device.d.ts +32 -0
  25. package/dist/builtins/camera-grid/grid-child.d.ts +9 -0
  26. package/dist/builtins/camera-grid/grid-device-settings.d.ts +11 -0
  27. package/dist/builtins/camera-grid/grid-filter-graph.d.ts +53 -0
  28. package/dist/builtins/camera-grid/grid-frame-sample.d.ts +27 -0
  29. package/dist/builtins/camera-grid/grid-instances.d.ts +49 -0
  30. package/dist/builtins/camera-grid/grid-last-frame-store.d.ts +19 -0
  31. package/dist/builtins/camera-grid/grid-layout-native-provider.d.ts +8 -0
  32. package/dist/builtins/camera-grid/grid-plan.d.ts +22 -0
  33. package/dist/builtins/camera-grid/grid-profiles.d.ts +47 -0
  34. package/dist/builtins/camera-grid/grid-row-normalization.d.ts +10 -0
  35. package/dist/builtins/camera-grid/grid-sentry-sources.d.ts +52 -0
  36. package/dist/builtins/camera-grid/grid-snapshot.d.ts +62 -0
  37. package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +24 -0
  38. package/dist/builtins/camera-grid/grid-stream-invocation.d.ts +29 -0
  39. package/dist/builtins/camera-grid/grid-stream-relay.d.ts +36 -0
  40. package/dist/builtins/camera-grid/grid-stream-session.d.ts +113 -0
  41. package/dist/builtins/camera-grid/grid-wire-format.d.ts +14 -0
  42. package/dist/builtins/camera-grid/index.d.ts +44 -0
  43. package/dist/builtins/camera-grid/index.js +2309 -0
  44. package/dist/builtins/camera-grid/index.mjs +2276 -0
  45. package/dist/builtins/camera-grid/silence-analysis.d.ts +13 -0
  46. package/dist/builtins/console-logging/index.js +1 -1
  47. package/dist/builtins/console-logging/index.mjs +1 -1
  48. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  49. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  50. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  51. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  52. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  53. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  54. package/dist/builtins/hub-forwarder/index.js +1 -1
  55. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  56. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  57. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  58. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  59. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  60. package/dist/builtins/local-network/local-network.addon.js +1 -1
  61. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  62. package/dist/builtins/loki-logging/index.js +1 -1
  63. package/dist/builtins/loki-logging/index.mjs +1 -1
  64. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  65. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  66. package/dist/builtins/platform-probe/index.js +1 -1
  67. package/dist/builtins/platform-probe/index.mjs +1 -1
  68. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  69. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  70. package/dist/builtins/snapshot/index.js +1 -1
  71. package/dist/builtins/snapshot/index.mjs +1 -1
  72. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  73. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  74. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  75. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  76. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  77. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  78. package/dist/builtins/system-config/system-config.addon.js +1 -1
  79. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  80. package/dist/builtins/winston-logging/index.js +1 -1
  81. package/dist/builtins/winston-logging/index.mjs +1 -1
  82. package/dist/{dist-CnQsUe15.js → dist-BJj6Akye.js} +1319 -0
  83. package/dist/{dist-BhTy3CUQ.mjs → dist-Ch93tyHB.mjs} +1260 -1
  84. package/dist/index.js +1 -1
  85. package/dist/index.mjs +1 -1
  86. package/dist/{retired-settings-keys-DDU-VFTU.js → retired-settings-keys-BRCn6-mt.js} +1 -1
  87. package/dist/{retired-settings-keys-CMhNYMD9.mjs → retired-settings-keys-uCaSKuwd.mjs} +1 -1
  88. 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;