@camstack/system 1.1.51 → 1.1.52
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/addon-runner.js +2 -2
- package/dist/addon-runner.mjs +2 -2
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- 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/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/device-manager/device-linked-devices.d.ts +47 -0
- package/dist/builtins/device-manager/device-manager.addon.js +1840 -1712
- package/dist/builtins/device-manager/device-manager.addon.mjs +1840 -1712
- package/dist/builtins/doorbell/index.d.ts +2 -0
- package/dist/builtins/doorbell/index.js +8 -0
- package/dist/builtins/doorbell/index.mjs +2 -0
- package/dist/builtins/doorbell/trigger-engine.d.ts +69 -0
- package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +87 -0
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +489 -0
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +483 -0
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +2 -2
- package/dist/builtins/local-auth/local-auth.addon.mjs +2 -2
- 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/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 +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- 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 +4 -4
- package/dist/builtins/system-config/system-config.addon.mjs +4 -4
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BWQX9yUj.mjs → dist-B_PSvr-m.mjs} +228 -12
- package/dist/{dist-C3uHEBtP.js → dist-BduBhv6N.js} +233 -11
- package/dist/http/file-data-plane.d.ts +8 -0
- package/dist/index.js +3 -3
- package/dist/index.mjs +3 -3
- package/dist/{manifest-python-deps-BA6KA9If.mjs → manifest-python-deps-CIyvuHAB.mjs} +1 -1
- package/dist/{manifest-python-deps-CVcJ9hDX.js → manifest-python-deps-CPAMH5gV.js} +1 -1
- package/dist/{model-download-service-C-IHWnXx.mjs → model-download-service-Cp9f4dk6.mjs} +1 -1
- package/dist/{model-download-service-D-Umz4-L.js → model-download-service-hf0ookyy.js} +1 -1
- package/package.json +13 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
require("../../chunk-Cek0wNdY.js");
|
|
6
|
+
const require_builtins_doorbell_virtual_doorbell_addon = require("./virtual-doorbell.addon.js");
|
|
7
|
+
exports.VirtualDoorbellAddon = require_builtins_doorbell_virtual_doorbell_addon.VirtualDoorbellAddon;
|
|
8
|
+
exports.default = require_builtins_doorbell_virtual_doorbell_addon.VirtualDoorbellAddon;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure trigger logic for the virtual-doorbell builtin — edge detection
|
|
3
|
+
* over runtime-state slices, event-emitter matching, and per-camera
|
|
4
|
+
* debounce. Extracted from the addon class (mirrors the
|
|
5
|
+
* `snapshot-coalescing.ts` split) so the behaviour is unit-testable
|
|
6
|
+
* without instantiating the addon or mocking the event bus.
|
|
7
|
+
*/
|
|
8
|
+
/** Trigger kinds an operator can configure per camera. */
|
|
9
|
+
export type DoorbellTrigger = 'contact-open' | 'contact-close' | 'switch-on' | 'event';
|
|
10
|
+
export declare const DOORBELL_TRIGGERS: readonly DoorbellTrigger[];
|
|
11
|
+
/** Type guard for {@link DoorbellTrigger}. */
|
|
12
|
+
export declare function isDoorbellTrigger(value: unknown): value is DoorbellTrigger;
|
|
13
|
+
/**
|
|
14
|
+
* One camera's virtual-doorbell wiring: which source device drives it,
|
|
15
|
+
* on which trigger, and (for `trigger: 'event'`) which event type.
|
|
16
|
+
*/
|
|
17
|
+
export interface DoorbellAssignment {
|
|
18
|
+
readonly cameraId: number;
|
|
19
|
+
readonly sourceDeviceId: number;
|
|
20
|
+
readonly trigger: DoorbellTrigger;
|
|
21
|
+
/** Only meaningful when `trigger === 'event'` — matched verbatim
|
|
22
|
+
* against the event-emitter `onEvent` payload `eventType`. */
|
|
23
|
+
readonly eventType: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** Debounce window guarding against sensor chatter (reed-switch bounce,
|
|
26
|
+
* duplicate HA state pushes). One ring per camera per window. */
|
|
27
|
+
export declare const DEFAULT_DEBOUNCE_MS = 2000;
|
|
28
|
+
/**
|
|
29
|
+
* Stateful (but side-effect-free towards the outside) trigger engine.
|
|
30
|
+
*
|
|
31
|
+
* Tracks the last observed binary value per (sourceDevice, cap) so a
|
|
32
|
+
* `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves
|
|
33
|
+
* while the binary value does not) never double-fires, and the FIRST
|
|
34
|
+
* observation after boot only seeds the baseline — a state hydration
|
|
35
|
+
* at startup must not ring anybody's doorbell.
|
|
36
|
+
*/
|
|
37
|
+
export declare class DoorbellTriggerEngine {
|
|
38
|
+
private assignments;
|
|
39
|
+
/** Last observed binary value keyed by `${deviceId}:${capName}`. */
|
|
40
|
+
private readonly lastValues;
|
|
41
|
+
/** Last fire timestamp per camera — debounce bookkeeping. */
|
|
42
|
+
private readonly lastFiredAt;
|
|
43
|
+
private readonly debounceMs;
|
|
44
|
+
private readonly now;
|
|
45
|
+
constructor(options?: {
|
|
46
|
+
readonly debounceMs?: number;
|
|
47
|
+
readonly now?: () => number;
|
|
48
|
+
});
|
|
49
|
+
/** Replace the full assignment set (rebuilt whenever settings change). */
|
|
50
|
+
setAssignments(assignments: readonly DoorbellAssignment[]): void;
|
|
51
|
+
getAssignments(): readonly DoorbellAssignment[];
|
|
52
|
+
/** True when any assignment references `deviceId` as its source. */
|
|
53
|
+
hasSource(deviceId: number): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Feed one `DeviceStateChanged` slice. Returns the camera ids whose
|
|
56
|
+
* doorbell should ring (already debounced). Baseline-only sightings
|
|
57
|
+
* and non-transitions return an empty array.
|
|
58
|
+
*/
|
|
59
|
+
onStateSlice(deviceId: number, capName: string, slice: Record<string, unknown>): number[];
|
|
60
|
+
/**
|
|
61
|
+
* Feed one event-emitter `onEvent` firing. Returns the camera ids to
|
|
62
|
+
* ring — assignments with `trigger: 'event'`, a matching source and a
|
|
63
|
+
* verbatim `eventType` match. An assignment with no configured
|
|
64
|
+
* eventType never matches (misconfiguration must not ring on every
|
|
65
|
+
* event the device emits).
|
|
66
|
+
*/
|
|
67
|
+
onEmitterEvent(deviceId: number, eventType: string): number[];
|
|
68
|
+
private matchAndDebounce;
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ProviderRegistration, BaseAddon } from '@camstack/types';
|
|
2
|
+
interface VirtualDoorbellAddonConfig {
|
|
3
|
+
/** Per-camera debounce window (ms) against sensor chatter. */
|
|
4
|
+
readonly debounceMs: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* VirtualDoorbellAddon — WRAPPER over the `doorbell` capability.
|
|
8
|
+
*
|
|
9
|
+
* Turns any binary-ish device (contact sensor, switch, event emitter,
|
|
10
|
+
* flood/gas/CO sensor) into a doorbell for a camera:
|
|
11
|
+
*
|
|
12
|
+
* 1. The operator binds the wrapper to a camera in the bindings UI
|
|
13
|
+
* (`defaultActive: false` — never auto-bound).
|
|
14
|
+
* 2. Per-camera settings pick the SOURCE device + trigger
|
|
15
|
+
* (`contact-open` / `contact-close` / `switch-on` / `event`
|
|
16
|
+
* with an event-type match).
|
|
17
|
+
* 3. At runtime the addon watches `EventCategory.DeviceStateChanged`
|
|
18
|
+
* (contact/switch slice transitions — EDGE detected per trigger)
|
|
19
|
+
* and `EventCategory.EventEmitted` (event-emitter firings). On a
|
|
20
|
+
* matching, debounced trigger it emits
|
|
21
|
+
* `EventCategory.DoorbellOnPressed` with the CAMERA as source
|
|
22
|
+
* (`{deviceId: cameraId, timestamp}` — the same shape Reolink's
|
|
23
|
+
* native `emitDoorbellPressed` produces) and bumps the camera's
|
|
24
|
+
* `doorbell` runtime-state counters via the standard
|
|
25
|
+
* `deviceState.setCapSlice` plumbing.
|
|
26
|
+
*
|
|
27
|
+
* Fires ONLY for cameras where the wrapper is BOUND (checked against
|
|
28
|
+
* `deviceManager.getBindings`) and a source device is configured.
|
|
29
|
+
*/
|
|
30
|
+
export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddonConfig> {
|
|
31
|
+
private readonly engine;
|
|
32
|
+
/** Monotonic guard: assignments are reloaded lazily on trigger traffic. */
|
|
33
|
+
private assignmentsLoadedAt;
|
|
34
|
+
private assignmentsLoading;
|
|
35
|
+
constructor();
|
|
36
|
+
protected onInitialize(): Promise<ProviderRegistration[]>;
|
|
37
|
+
private handleStateChanged;
|
|
38
|
+
private handleEmitterEvent;
|
|
39
|
+
/**
|
|
40
|
+
* Ring the camera's virtual doorbell: verify the wrapper is BOUND to
|
|
41
|
+
* the camera, bump the `doorbell` runtime-state counters through the
|
|
42
|
+
* canonical `deviceState.setCapSlice` write, and emit the typed
|
|
43
|
+
* `DoorbellOnPressed` event with the CAMERA as source — byte-shaped
|
|
44
|
+
* like a native firmware ring so every downstream consumer (UI
|
|
45
|
+
* toast, notifier rules, exporters) is agnostic to the origin.
|
|
46
|
+
*/
|
|
47
|
+
private fireDoorbell;
|
|
48
|
+
/** True when THIS wrapper is the active `doorbell` binding for the camera. */
|
|
49
|
+
private isWrapperBound;
|
|
50
|
+
/**
|
|
51
|
+
* Lazily (re)build the camera→source assignment set from every
|
|
52
|
+
* camera's device store. TTL-bounded so devices/settings appearing
|
|
53
|
+
* after boot are picked up without a settings save; invalidated
|
|
54
|
+
* eagerly by `applyDeviceSettingsPatch`.
|
|
55
|
+
*/
|
|
56
|
+
private ensureAssignments;
|
|
57
|
+
private loadAssignments;
|
|
58
|
+
private invalidateAssignments;
|
|
59
|
+
/**
|
|
60
|
+
* Status from the kernel-managed `doorbell` runtime-state slice —
|
|
61
|
+
* the wrapper keeps NO parallel counter fields (the slice is the
|
|
62
|
+
* single source of truth, persisted across restarts).
|
|
63
|
+
*/
|
|
64
|
+
private getStatus;
|
|
65
|
+
private readDeviceSettings;
|
|
66
|
+
/**
|
|
67
|
+
* Single-trip, non-throwing device lookup (mirrors snapshot's
|
|
68
|
+
* `lookupDeviceMeta`). Null on any failure — callers fall back to
|
|
69
|
+
* rendering the section (better a spurious section than a thrown
|
|
70
|
+
* aggregator entry).
|
|
71
|
+
*/
|
|
72
|
+
private lookupDevice;
|
|
73
|
+
/**
|
|
74
|
+
* Settings section for the device-detail page. Camera-only — the
|
|
75
|
+
* doorbell cap also applies to Button accessories (native providers),
|
|
76
|
+
* where a virtual-source picker is meaningless.
|
|
77
|
+
*/
|
|
78
|
+
private buildDeviceSettingsContribution;
|
|
79
|
+
/**
|
|
80
|
+
* Cluster-wide picker options: every device whose BINDINGS expose one
|
|
81
|
+
* of the binary-ish source caps. Binding-sourced (not feature-flag
|
|
82
|
+
* sourced) because cap names are exactly what the runtime consumes.
|
|
83
|
+
*/
|
|
84
|
+
private listSourceDeviceOptions;
|
|
85
|
+
private saveDeviceSettingsPatch;
|
|
86
|
+
}
|
|
87
|
+
export default VirtualDoorbellAddon;
|
|
@@ -0,0 +1,489 @@
|
|
|
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-BduBhv6N.js");
|
|
7
|
+
//#region src/builtins/doorbell/trigger-engine.ts
|
|
8
|
+
var DOORBELL_TRIGGERS = [
|
|
9
|
+
"contact-open",
|
|
10
|
+
"contact-close",
|
|
11
|
+
"switch-on",
|
|
12
|
+
"event"
|
|
13
|
+
];
|
|
14
|
+
/** Type guard for {@link DoorbellTrigger}. */
|
|
15
|
+
function isDoorbellTrigger(value) {
|
|
16
|
+
return typeof value === "string" && DOORBELL_TRIGGERS.includes(value);
|
|
17
|
+
}
|
|
18
|
+
/** Cap slices the engine understands as binary trigger sources. */
|
|
19
|
+
var CONTACT_CAP = "contact";
|
|
20
|
+
var SWITCH_CAP = "switch";
|
|
21
|
+
/**
|
|
22
|
+
* Map an observed boolean transition on a source cap to the trigger it
|
|
23
|
+
* satisfies. `null` when the transition has no configured meaning
|
|
24
|
+
* (e.g. switch turning OFF).
|
|
25
|
+
*/
|
|
26
|
+
function edgeToTrigger(capName, value) {
|
|
27
|
+
if (capName === CONTACT_CAP) return value ? "contact-open" : "contact-close";
|
|
28
|
+
if (capName === SWITCH_CAP) return value ? "switch-on" : null;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** Extract the binary state carried by a contact / switch slice. */
|
|
32
|
+
function sliceBinaryValue(capName, slice) {
|
|
33
|
+
const raw = capName === CONTACT_CAP ? slice["entryOpen"] : slice["on"];
|
|
34
|
+
return typeof raw === "boolean" ? raw : null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Stateful (but side-effect-free towards the outside) trigger engine.
|
|
38
|
+
*
|
|
39
|
+
* Tracks the last observed binary value per (sourceDevice, cap) so a
|
|
40
|
+
* `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves
|
|
41
|
+
* while the binary value does not) never double-fires, and the FIRST
|
|
42
|
+
* observation after boot only seeds the baseline — a state hydration
|
|
43
|
+
* at startup must not ring anybody's doorbell.
|
|
44
|
+
*/
|
|
45
|
+
var DoorbellTriggerEngine = class {
|
|
46
|
+
assignments = [];
|
|
47
|
+
/** Last observed binary value keyed by `${deviceId}:${capName}`. */
|
|
48
|
+
lastValues = /* @__PURE__ */ new Map();
|
|
49
|
+
/** Last fire timestamp per camera — debounce bookkeeping. */
|
|
50
|
+
lastFiredAt = /* @__PURE__ */ new Map();
|
|
51
|
+
debounceMs;
|
|
52
|
+
now;
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.debounceMs = options?.debounceMs ?? 2e3;
|
|
55
|
+
this.now = options?.now ?? Date.now;
|
|
56
|
+
}
|
|
57
|
+
/** Replace the full assignment set (rebuilt whenever settings change). */
|
|
58
|
+
setAssignments(assignments) {
|
|
59
|
+
this.assignments = [...assignments];
|
|
60
|
+
}
|
|
61
|
+
getAssignments() {
|
|
62
|
+
return this.assignments;
|
|
63
|
+
}
|
|
64
|
+
/** True when any assignment references `deviceId` as its source. */
|
|
65
|
+
hasSource(deviceId) {
|
|
66
|
+
return this.assignments.some((a) => a.sourceDeviceId === deviceId);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Feed one `DeviceStateChanged` slice. Returns the camera ids whose
|
|
70
|
+
* doorbell should ring (already debounced). Baseline-only sightings
|
|
71
|
+
* and non-transitions return an empty array.
|
|
72
|
+
*/
|
|
73
|
+
onStateSlice(deviceId, capName, slice) {
|
|
74
|
+
if (capName !== CONTACT_CAP && capName !== SWITCH_CAP) return [];
|
|
75
|
+
const value = sliceBinaryValue(capName, slice);
|
|
76
|
+
if (value === null) return [];
|
|
77
|
+
const key = `${deviceId}:${capName}`;
|
|
78
|
+
const prior = this.lastValues.get(key);
|
|
79
|
+
this.lastValues.set(key, value);
|
|
80
|
+
if (prior === void 0 || prior === value) return [];
|
|
81
|
+
const trigger = edgeToTrigger(capName, value);
|
|
82
|
+
if (!trigger) return [];
|
|
83
|
+
return this.matchAndDebounce((a) => a.sourceDeviceId === deviceId && a.trigger === trigger);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Feed one event-emitter `onEvent` firing. Returns the camera ids to
|
|
87
|
+
* ring — assignments with `trigger: 'event'`, a matching source and a
|
|
88
|
+
* verbatim `eventType` match. An assignment with no configured
|
|
89
|
+
* eventType never matches (misconfiguration must not ring on every
|
|
90
|
+
* event the device emits).
|
|
91
|
+
*/
|
|
92
|
+
onEmitterEvent(deviceId, eventType) {
|
|
93
|
+
return this.matchAndDebounce((a) => a.trigger === "event" && a.sourceDeviceId === deviceId && a.eventType !== null && a.eventType.length > 0 && a.eventType === eventType);
|
|
94
|
+
}
|
|
95
|
+
matchAndDebounce(predicate) {
|
|
96
|
+
const now = this.now();
|
|
97
|
+
const fired = [];
|
|
98
|
+
for (const assignment of this.assignments) {
|
|
99
|
+
if (!predicate(assignment)) continue;
|
|
100
|
+
const last = this.lastFiredAt.get(assignment.cameraId);
|
|
101
|
+
if (last !== void 0 && now - last < this.debounceMs) continue;
|
|
102
|
+
this.lastFiredAt.set(assignment.cameraId, now);
|
|
103
|
+
fired.push(assignment.cameraId);
|
|
104
|
+
}
|
|
105
|
+
return fired;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/builtins/doorbell/virtual-doorbell.addon.ts
|
|
110
|
+
/** Device-store keys (namespaced to avoid collisions in merged stores). */
|
|
111
|
+
var KEY_SOURCE = "doorbellSourceDeviceId";
|
|
112
|
+
var KEY_TRIGGER = "doorbellTrigger";
|
|
113
|
+
var KEY_EVENT_TYPE = "doorbellEventType";
|
|
114
|
+
var DEFAULT_TRIGGER = "contact-open";
|
|
115
|
+
/**
|
|
116
|
+
* Caps whose presence in a device's bindings marks it as a usable
|
|
117
|
+
* doorbell SOURCE — anything with a binary-ish surface. Deliberately
|
|
118
|
+
* not gated on device linking: which device rings which camera is an
|
|
119
|
+
* operator decision.
|
|
120
|
+
*/
|
|
121
|
+
var SOURCE_CAPS = [
|
|
122
|
+
"contact",
|
|
123
|
+
"flood",
|
|
124
|
+
"gas",
|
|
125
|
+
"carbon-monoxide",
|
|
126
|
+
"enum-sensor",
|
|
127
|
+
"event-emitter",
|
|
128
|
+
"switch"
|
|
129
|
+
];
|
|
130
|
+
/** Reload the camera→source assignment map at most this often (lazily,
|
|
131
|
+
* on trigger traffic). Covers devices/settings appearing after boot
|
|
132
|
+
* without a settings save. */
|
|
133
|
+
var ASSIGNMENTS_TTL_MS = 6e4;
|
|
134
|
+
var TRIGGER_LABELS = {
|
|
135
|
+
"contact-open": "Contact opens",
|
|
136
|
+
"contact-close": "Contact closes",
|
|
137
|
+
"switch-on": "Switch turns on",
|
|
138
|
+
event: "Device event"
|
|
139
|
+
};
|
|
140
|
+
/** Structural narrowing helper for untrusted bus payloads. */
|
|
141
|
+
function isRecord(value) {
|
|
142
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Cast-free narrowing of the untyped `device.state-changed` payload
|
|
146
|
+
* (the category is not part of the typed EventCatalog — subscribers
|
|
147
|
+
* validate at the boundary).
|
|
148
|
+
*/
|
|
149
|
+
function parseDeviceStateChanged(data) {
|
|
150
|
+
if (!isRecord(data)) return null;
|
|
151
|
+
const deviceId = data["deviceId"];
|
|
152
|
+
const capName = data["capName"];
|
|
153
|
+
const slice = data["slice"];
|
|
154
|
+
if (typeof deviceId !== "number" || typeof capName !== "string") return null;
|
|
155
|
+
if (!isRecord(slice)) return null;
|
|
156
|
+
return {
|
|
157
|
+
deviceId,
|
|
158
|
+
capName,
|
|
159
|
+
slice
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Narrow a raw `doorbell` runtime-state slice to counters (defaults on absence). */
|
|
163
|
+
function parseDoorbellSlice(slice) {
|
|
164
|
+
const lastPressedAtRaw = slice?.["lastPressedAt"];
|
|
165
|
+
const countRaw = slice?.["pressCountSinceStart"];
|
|
166
|
+
return {
|
|
167
|
+
lastPressedAt: typeof lastPressedAtRaw === "number" ? lastPressedAtRaw : null,
|
|
168
|
+
pressCountSinceStart: typeof countRaw === "number" && Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* VirtualDoorbellAddon — WRAPPER over the `doorbell` capability.
|
|
173
|
+
*
|
|
174
|
+
* Turns any binary-ish device (contact sensor, switch, event emitter,
|
|
175
|
+
* flood/gas/CO sensor) into a doorbell for a camera:
|
|
176
|
+
*
|
|
177
|
+
* 1. The operator binds the wrapper to a camera in the bindings UI
|
|
178
|
+
* (`defaultActive: false` — never auto-bound).
|
|
179
|
+
* 2. Per-camera settings pick the SOURCE device + trigger
|
|
180
|
+
* (`contact-open` / `contact-close` / `switch-on` / `event`
|
|
181
|
+
* with an event-type match).
|
|
182
|
+
* 3. At runtime the addon watches `EventCategory.DeviceStateChanged`
|
|
183
|
+
* (contact/switch slice transitions — EDGE detected per trigger)
|
|
184
|
+
* and `EventCategory.EventEmitted` (event-emitter firings). On a
|
|
185
|
+
* matching, debounced trigger it emits
|
|
186
|
+
* `EventCategory.DoorbellOnPressed` with the CAMERA as source
|
|
187
|
+
* (`{deviceId: cameraId, timestamp}` — the same shape Reolink's
|
|
188
|
+
* native `emitDoorbellPressed` produces) and bumps the camera's
|
|
189
|
+
* `doorbell` runtime-state counters via the standard
|
|
190
|
+
* `deviceState.setCapSlice` plumbing.
|
|
191
|
+
*
|
|
192
|
+
* Fires ONLY for cameras where the wrapper is BOUND (checked against
|
|
193
|
+
* `deviceManager.getBindings`) and a source device is configured.
|
|
194
|
+
*/
|
|
195
|
+
var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
|
|
196
|
+
engine;
|
|
197
|
+
/** Monotonic guard: assignments are reloaded lazily on trigger traffic. */
|
|
198
|
+
assignmentsLoadedAt = 0;
|
|
199
|
+
assignmentsLoading = null;
|
|
200
|
+
constructor() {
|
|
201
|
+
super({ debounceMs: 2e3 });
|
|
202
|
+
this.engine = new DoorbellTriggerEngine({ debounceMs: 2e3 });
|
|
203
|
+
}
|
|
204
|
+
async onInitialize() {
|
|
205
|
+
this.ctx.logger.info("Virtual doorbell wrapper initialized");
|
|
206
|
+
this.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (event) => {
|
|
207
|
+
const data = parseDeviceStateChanged(event.data);
|
|
208
|
+
if (!data) return;
|
|
209
|
+
this.handleStateChanged(data);
|
|
210
|
+
});
|
|
211
|
+
this.subscribe({ category: require_dist.EventCategory.EventEmitted }, (event) => {
|
|
212
|
+
this.handleEmitterEvent(event.data.deviceId, event.data.eventType);
|
|
213
|
+
});
|
|
214
|
+
return [{
|
|
215
|
+
capability: require_dist.doorbellCapability,
|
|
216
|
+
provider: {
|
|
217
|
+
getStatus: (input) => this.getStatus(input.deviceId),
|
|
218
|
+
getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
|
|
219
|
+
getDeviceLiveContribution: async () => null,
|
|
220
|
+
applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch)
|
|
221
|
+
}
|
|
222
|
+
}];
|
|
223
|
+
}
|
|
224
|
+
async handleStateChanged(data) {
|
|
225
|
+
if (data.capName !== "contact" && data.capName !== "switch") return;
|
|
226
|
+
await this.ensureAssignments();
|
|
227
|
+
const cameraIds = this.engine.onStateSlice(data.deviceId, data.capName, data.slice);
|
|
228
|
+
for (const cameraId of cameraIds) await this.fireDoorbell(cameraId, Date.now());
|
|
229
|
+
}
|
|
230
|
+
async handleEmitterEvent(sourceDeviceId, eventType) {
|
|
231
|
+
await this.ensureAssignments();
|
|
232
|
+
const cameraIds = this.engine.onEmitterEvent(sourceDeviceId, eventType);
|
|
233
|
+
for (const cameraId of cameraIds) await this.fireDoorbell(cameraId, Date.now());
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Ring the camera's virtual doorbell: verify the wrapper is BOUND to
|
|
237
|
+
* the camera, bump the `doorbell` runtime-state counters through the
|
|
238
|
+
* canonical `deviceState.setCapSlice` write, and emit the typed
|
|
239
|
+
* `DoorbellOnPressed` event with the CAMERA as source — byte-shaped
|
|
240
|
+
* like a native firmware ring so every downstream consumer (UI
|
|
241
|
+
* toast, notifier rules, exporters) is agnostic to the origin.
|
|
242
|
+
*/
|
|
243
|
+
async fireDoorbell(cameraId, timestamp) {
|
|
244
|
+
if (!await this.isWrapperBound(cameraId)) {
|
|
245
|
+
this.ctx.logger.debug("virtual-doorbell: trigger matched but wrapper not bound — skipping", { tags: { deviceId: cameraId } });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const next = {
|
|
250
|
+
lastPressedAt: timestamp,
|
|
251
|
+
pressCountSinceStart: parseDoorbellSlice(await this.ctx.api.deviceState.getCapSlice.query({
|
|
252
|
+
deviceId: cameraId,
|
|
253
|
+
capName: require_dist.doorbellCapability.name
|
|
254
|
+
})).pressCountSinceStart + 1
|
|
255
|
+
};
|
|
256
|
+
await this.ctx.api.deviceState.setCapSlice.mutate({
|
|
257
|
+
deviceId: cameraId,
|
|
258
|
+
capName: require_dist.doorbellCapability.name,
|
|
259
|
+
slice: next
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
this.ctx.logger.warn("virtual-doorbell: runtime-state update failed", {
|
|
263
|
+
tags: { deviceId: cameraId },
|
|
264
|
+
meta: { error: require_dist.errMsg(err) }
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
this.ctx.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.DoorbellOnPressed, {
|
|
268
|
+
type: "device",
|
|
269
|
+
id: cameraId,
|
|
270
|
+
deviceId: cameraId,
|
|
271
|
+
addonId: this.ctx.id
|
|
272
|
+
}, {
|
|
273
|
+
deviceId: cameraId,
|
|
274
|
+
timestamp
|
|
275
|
+
}));
|
|
276
|
+
this.ctx.logger.info("virtual-doorbell: pressed", {
|
|
277
|
+
tags: { deviceId: cameraId },
|
|
278
|
+
meta: { timestamp }
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/** True when THIS wrapper is the active `doorbell` binding for the camera. */
|
|
282
|
+
async isWrapperBound(cameraId) {
|
|
283
|
+
try {
|
|
284
|
+
return (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries.some((entry) => entry.capName === require_dist.doorbellCapability.name && entry.kind === "wrapped" && entry.providerAddonId === this.ctx.id);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
this.ctx.logger.debug("virtual-doorbell: getBindings failed — treating as unbound", {
|
|
287
|
+
tags: { deviceId: cameraId },
|
|
288
|
+
meta: { error: require_dist.errMsg(err) }
|
|
289
|
+
});
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Lazily (re)build the camera→source assignment set from every
|
|
295
|
+
* camera's device store. TTL-bounded so devices/settings appearing
|
|
296
|
+
* after boot are picked up without a settings save; invalidated
|
|
297
|
+
* eagerly by `applyDeviceSettingsPatch`.
|
|
298
|
+
*/
|
|
299
|
+
async ensureAssignments() {
|
|
300
|
+
if (Date.now() - this.assignmentsLoadedAt < ASSIGNMENTS_TTL_MS) return;
|
|
301
|
+
if (this.assignmentsLoading) return this.assignmentsLoading;
|
|
302
|
+
this.assignmentsLoading = this.loadAssignments().then(() => {
|
|
303
|
+
this.assignmentsLoadedAt = Date.now();
|
|
304
|
+
}).catch((err) => {
|
|
305
|
+
this.ctx.logger.warn("virtual-doorbell: assignment reload failed", { meta: { error: require_dist.errMsg(err) } });
|
|
306
|
+
}).finally(() => {
|
|
307
|
+
this.assignmentsLoading = null;
|
|
308
|
+
});
|
|
309
|
+
return this.assignmentsLoading;
|
|
310
|
+
}
|
|
311
|
+
async loadAssignments() {
|
|
312
|
+
const cameras = (await this.ctx.api.deviceManager.listAll.query({})).filter((d) => d.type === require_dist.DeviceType.Camera);
|
|
313
|
+
const assignments = [];
|
|
314
|
+
for (const camera of cameras) {
|
|
315
|
+
const settings = await this.readDeviceSettings(camera.id);
|
|
316
|
+
if (settings.sourceDeviceId === null) continue;
|
|
317
|
+
assignments.push({
|
|
318
|
+
cameraId: camera.id,
|
|
319
|
+
sourceDeviceId: settings.sourceDeviceId,
|
|
320
|
+
trigger: settings.trigger,
|
|
321
|
+
eventType: settings.eventType.length > 0 ? settings.eventType : null
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
this.engine.setAssignments(assignments);
|
|
325
|
+
this.ctx.logger.debug("virtual-doorbell: assignments loaded", { meta: { count: assignments.length } });
|
|
326
|
+
}
|
|
327
|
+
invalidateAssignments() {
|
|
328
|
+
this.assignmentsLoadedAt = 0;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Status from the kernel-managed `doorbell` runtime-state slice —
|
|
332
|
+
* the wrapper keeps NO parallel counter fields (the slice is the
|
|
333
|
+
* single source of truth, persisted across restarts).
|
|
334
|
+
*/
|
|
335
|
+
async getStatus(deviceId) {
|
|
336
|
+
try {
|
|
337
|
+
return parseDoorbellSlice(await this.ctx.api.deviceState.getCapSlice.query({
|
|
338
|
+
deviceId,
|
|
339
|
+
capName: require_dist.doorbellCapability.name
|
|
340
|
+
}));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
this.ctx.logger.debug("virtual-doorbell: getCapSlice failed during getStatus", {
|
|
343
|
+
tags: { deviceId },
|
|
344
|
+
meta: { error: require_dist.errMsg(err) }
|
|
345
|
+
});
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async readDeviceSettings(deviceId) {
|
|
350
|
+
if (!this.ctx.settings) return {
|
|
351
|
+
sourceDeviceId: null,
|
|
352
|
+
trigger: DEFAULT_TRIGGER,
|
|
353
|
+
eventType: ""
|
|
354
|
+
};
|
|
355
|
+
const raw = await this.ctx.settings.readDeviceStore(deviceId);
|
|
356
|
+
const sourceRaw = raw[KEY_SOURCE];
|
|
357
|
+
const triggerRaw = raw[KEY_TRIGGER];
|
|
358
|
+
const eventTypeRaw = raw[KEY_EVENT_TYPE];
|
|
359
|
+
return {
|
|
360
|
+
sourceDeviceId: typeof sourceRaw === "number" && Number.isFinite(sourceRaw) && sourceRaw > 0 ? sourceRaw : null,
|
|
361
|
+
trigger: isDoorbellTrigger(triggerRaw) ? triggerRaw : DEFAULT_TRIGGER,
|
|
362
|
+
eventType: typeof eventTypeRaw === "string" ? eventTypeRaw : ""
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Single-trip, non-throwing device lookup (mirrors snapshot's
|
|
367
|
+
* `lookupDeviceMeta`). Null on any failure — callers fall back to
|
|
368
|
+
* rendering the section (better a spurious section than a thrown
|
|
369
|
+
* aggregator entry).
|
|
370
|
+
*/
|
|
371
|
+
async lookupDevice(deviceId) {
|
|
372
|
+
try {
|
|
373
|
+
const found = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
|
|
374
|
+
return found ? { type: found.type } : null;
|
|
375
|
+
} catch (err) {
|
|
376
|
+
this.ctx.logger.debug("virtual-doorbell: getDevice failed during contribution", {
|
|
377
|
+
tags: { deviceId },
|
|
378
|
+
meta: { error: require_dist.errMsg(err) }
|
|
379
|
+
});
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Settings section for the device-detail page. Camera-only — the
|
|
385
|
+
* doorbell cap also applies to Button accessories (native providers),
|
|
386
|
+
* where a virtual-source picker is meaningless.
|
|
387
|
+
*/
|
|
388
|
+
async buildDeviceSettingsContribution(deviceId) {
|
|
389
|
+
const device = await this.lookupDevice(deviceId);
|
|
390
|
+
if (device && device.type !== require_dist.DeviceType.Camera) return null;
|
|
391
|
+
const current = await this.readDeviceSettings(deviceId);
|
|
392
|
+
return { sections: [{
|
|
393
|
+
id: "virtual-doorbell",
|
|
394
|
+
title: "Virtual Doorbell",
|
|
395
|
+
tab: "doorbell",
|
|
396
|
+
order: 70,
|
|
397
|
+
fields: [
|
|
398
|
+
{
|
|
399
|
+
type: "select",
|
|
400
|
+
key: KEY_SOURCE,
|
|
401
|
+
label: "Source device",
|
|
402
|
+
description: "Device whose state change rings this camera’s doorbell (contact, switch, sensor, event emitter).",
|
|
403
|
+
options: await this.listSourceDeviceOptions(),
|
|
404
|
+
required: true,
|
|
405
|
+
value: current.sourceDeviceId === null ? "none" : String(current.sourceDeviceId)
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
type: "select",
|
|
409
|
+
key: KEY_TRIGGER,
|
|
410
|
+
label: "Trigger",
|
|
411
|
+
description: "Which transition of the source device counts as a ring.",
|
|
412
|
+
options: DOORBELL_TRIGGERS.map((t) => ({
|
|
413
|
+
value: t,
|
|
414
|
+
label: TRIGGER_LABELS[t]
|
|
415
|
+
})),
|
|
416
|
+
required: true,
|
|
417
|
+
value: current.trigger
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
type: "text",
|
|
421
|
+
key: KEY_EVENT_TYPE,
|
|
422
|
+
label: "Event type",
|
|
423
|
+
description: "Only used when Trigger is 'Device event' — matched verbatim against the source device's emitted event type (e.g. press_short).",
|
|
424
|
+
value: current.eventType
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
}] };
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Cluster-wide picker options: every device whose BINDINGS expose one
|
|
431
|
+
* of the binary-ish source caps. Binding-sourced (not feature-flag
|
|
432
|
+
* sourced) because cap names are exactly what the runtime consumes.
|
|
433
|
+
*/
|
|
434
|
+
async listSourceDeviceOptions() {
|
|
435
|
+
const none = {
|
|
436
|
+
value: "none",
|
|
437
|
+
label: "None"
|
|
438
|
+
};
|
|
439
|
+
try {
|
|
440
|
+
const [devices, allBindings] = await Promise.all([this.ctx.api.deviceManager.listAll.query({}), this.ctx.api.deviceManager.getAllBindings.query({})]);
|
|
441
|
+
const eligibleIds = /* @__PURE__ */ new Set();
|
|
442
|
+
for (const deviceBindings of allBindings) if (deviceBindings.entries.some((e) => SOURCE_CAPS.includes(e.capName))) eligibleIds.add(deviceBindings.deviceId);
|
|
443
|
+
return [none, ...devices.filter((d) => eligibleIds.has(d.id)).map((d) => ({
|
|
444
|
+
value: String(d.id),
|
|
445
|
+
label: `${d.name} (#${d.id})`
|
|
446
|
+
})).toSorted((a, b) => a.label.localeCompare(b.label))];
|
|
447
|
+
} catch (err) {
|
|
448
|
+
this.ctx.logger.warn("virtual-doorbell: source-device enumeration failed", { meta: { error: require_dist.errMsg(err) } });
|
|
449
|
+
return [none];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async saveDeviceSettingsPatch(deviceId, patch) {
|
|
453
|
+
if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
|
|
454
|
+
const next = { ...await this.ctx.settings.readDeviceStore(deviceId) };
|
|
455
|
+
if (KEY_SOURCE in patch) {
|
|
456
|
+
const parsed = parseSourceDeviceId(patch[KEY_SOURCE]);
|
|
457
|
+
if (parsed === null) delete next[KEY_SOURCE];
|
|
458
|
+
else next[KEY_SOURCE] = parsed;
|
|
459
|
+
}
|
|
460
|
+
if (KEY_TRIGGER in patch) {
|
|
461
|
+
const v = patch[KEY_TRIGGER];
|
|
462
|
+
if (isDoorbellTrigger(v)) next[KEY_TRIGGER] = v;
|
|
463
|
+
else delete next[KEY_TRIGGER];
|
|
464
|
+
}
|
|
465
|
+
if (KEY_EVENT_TYPE in patch) {
|
|
466
|
+
const v = patch[KEY_EVENT_TYPE];
|
|
467
|
+
next[KEY_EVENT_TYPE] = typeof v === "string" ? v.trim() : "";
|
|
468
|
+
}
|
|
469
|
+
await this.ctx.settings.writeDeviceStore(deviceId, next);
|
|
470
|
+
this.invalidateAssignments();
|
|
471
|
+
return { success: true };
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
/**
|
|
475
|
+
* Coerce the select value ('none' | numeric string | number) back to a
|
|
476
|
+
* device id. The select field surfaces string values; a stored value
|
|
477
|
+
* round-trips as a number.
|
|
478
|
+
*/
|
|
479
|
+
function parseSourceDeviceId(value) {
|
|
480
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
481
|
+
if (typeof value === "string" && value !== "none" && value.trim().length > 0) {
|
|
482
|
+
const parsed = Number(value);
|
|
483
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
exports.VirtualDoorbellAddon = VirtualDoorbellAddon;
|
|
489
|
+
exports.default = VirtualDoorbellAddon;
|