@camstack/system 1.2.305 → 1.2.306
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/index.js +1 -1
- package/dist/builtins/autotrack/index.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/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- 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-extension-linked-slot.d.ts +19 -0
- package/dist/builtins/device-manager/device-linked-devices.d.ts +26 -18
- package/dist/builtins/device-manager/device-manager.addon.js +157 -135
- package/dist/builtins/device-manager/device-manager.addon.mjs +157 -135
- package/dist/builtins/doorbell/binding-mirror.d.ts +13 -7
- package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +12 -45
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +29 -102
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +29 -102
- 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-Df1NhKxm.mjs → dist-CLfJMMdf.mjs} +40 -68
- package/dist/{dist-BK4iP6ab.js → dist-vecbhDzu.js} +39 -73
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-DwWMph6F.js → retired-settings-keys-Bm2p0Rot.js} +1 -1
- package/dist/{retired-settings-keys-dC4ckbXJ.mjs → retired-settings-keys-C1ODcdnr.mjs} +1 -1
- package/package.json +1 -1
- package/dist/builtins/device-manager/device-store-sections.d.ts +0 -26
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-
|
|
1
|
+
import { At as locationIconKey, Et as legacyAutoLinkIds, Hn as sleep$1, Ht as parseStreamParamsFormPatch, Pn as isDeviceConfigCap, Sn as DeviceType, Tn as WELL_KNOWN_TAB_MAP, Un as EventCategory, W as buildStreamParamsConfigSchema, X as createDeviceExtensionProvider, Xt as runtimeStatePolicyFor, at as deviceStatusCapability, bn as DeviceFeature, ct as enumerateItemArrayFields, d as DAY_NAMES, et as defineExtensionSlotProvider, ft as expandContainer, gn as BaseAddon, gt as formatMinutes, h as DeviceStatusSchema, it as deviceStateCapability, k as STREAM_PROFILE_META, lt as enumerateSchemaFields, mn as errMsg, nt as deviceExtensionCapability, o as CAMERA_SWITCH_ORDER, rt as deviceManagerCapability, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, tn as siteZoneKey, x as MINUTES_PER_DAY, xn as DeviceRole, y as LocationIconIdSchema, zt as normalizeUnit } from "../../dist-CLfJMMdf.mjs";
|
|
2
|
+
import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-C1ODcdnr.mjs";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { canonicalDeviceFingerprint } from "@camstack/types/node";
|
|
5
5
|
//#region src/builtins/device-manager/adoption-job-engine.ts
|
|
@@ -2715,6 +2715,16 @@ var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
|
|
|
2715
2715
|
* explicit operator OPT-IN (2026-07-22 flip: the old absent⇒ALL default
|
|
2716
2716
|
* auto-materialized sensor snapshots the operator never asked for). */
|
|
2717
2717
|
var LINKED_TRACKED_IDS_CONFIG_KEY = "_linkedDeviceTrackedIds";
|
|
2718
|
+
/**
|
|
2719
|
+
* The three reserved keys, as one set: they stay with a camera's NUMBER on a
|
|
2720
|
+
* hardware swap, and a settings save that still carries them (a page opened
|
|
2721
|
+
* before D655 retired the section) is refused by name, never dropped.
|
|
2722
|
+
*/
|
|
2723
|
+
var LINKED_DEVICES_RESERVED_KEYS = new Set([
|
|
2724
|
+
LINKED_MODE_CONFIG_KEY,
|
|
2725
|
+
LINKED_IDS_CONFIG_KEY,
|
|
2726
|
+
LINKED_TRACKED_IDS_CONFIG_KEY
|
|
2727
|
+
]);
|
|
2718
2728
|
/** Parse the two reserved keys out of a device-config blob. Tolerant:
|
|
2719
2729
|
* ids may arrive as numbers or numeric strings (the multiselect field
|
|
2720
2730
|
* stores string option values); anything else is dropped. */
|
|
@@ -2764,14 +2774,24 @@ function resolveLinkedDeviceIds(params) {
|
|
|
2764
2774
|
* are ignored.
|
|
2765
2775
|
*/
|
|
2766
2776
|
function resolveTrackedEventDeviceIds(config, linkedIds) {
|
|
2777
|
+
const picked = new Set(parseTrackedOptIn(config));
|
|
2778
|
+
return linkedIds.filter((id) => picked.has(id));
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* The persisted tracked-event opt-in AS STORED — not intersected with the
|
|
2782
|
+
* linked set, which is the resolver's job. Absent / non-array ⇒ `[]` (NONE,
|
|
2783
|
+
* the same answer the resolver gives). Ids may be numbers or numeric strings;
|
|
2784
|
+
* anything else is dropped; deduped, in stored order.
|
|
2785
|
+
*/
|
|
2786
|
+
function parseTrackedOptIn(config) {
|
|
2767
2787
|
const raw = config[LINKED_TRACKED_IDS_CONFIG_KEY];
|
|
2768
2788
|
if (!Array.isArray(raw)) return [];
|
|
2769
|
-
const
|
|
2789
|
+
const out = [];
|
|
2770
2790
|
for (const v of raw) {
|
|
2771
2791
|
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
|
|
2772
|
-
if (Number.isInteger(n))
|
|
2792
|
+
if (Number.isInteger(n) && !out.includes(n)) out.push(n);
|
|
2773
2793
|
}
|
|
2774
|
-
return
|
|
2794
|
+
return out;
|
|
2775
2795
|
}
|
|
2776
2796
|
/**
|
|
2777
2797
|
* The fleet listing every function here needs, and the ONLY projection any of
|
|
@@ -2864,76 +2884,16 @@ async function getLinkedDevicesBatch(pctx, input) {
|
|
|
2864
2884
|
});
|
|
2865
2885
|
}
|
|
2866
2886
|
/**
|
|
2867
|
-
*
|
|
2868
|
-
*
|
|
2869
|
-
*
|
|
2870
|
-
*
|
|
2871
|
-
|
|
2872
|
-
async function buildLinkedDevicesContribution(pctx, deviceId) {
|
|
2873
|
-
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
2874
|
-
if (!persisted || persisted.meta.type !== DeviceType.Camera) return null;
|
|
2875
|
-
const [all, blob] = await Promise.all([listAll(pctx, LINK_CANDIDATE_PROJECTION), pctx.settings.readDeviceStore(deviceId)]);
|
|
2876
|
-
const config = parseLinkedDevicesConfig(blob);
|
|
2877
|
-
const self = all.find((d) => d.id === deviceId);
|
|
2878
|
-
const linkedIds = resolveLinkedDeviceIds({
|
|
2879
|
-
selfId: deviceId,
|
|
2880
|
-
config,
|
|
2881
|
-
candidates: all
|
|
2882
|
-
});
|
|
2883
|
-
const byId = new Map(all.map((d) => [d.id, d]));
|
|
2884
|
-
const trackedOptions = linkedIds.flatMap((id) => {
|
|
2885
|
-
const d = byId.get(id);
|
|
2886
|
-
if (!d) return [];
|
|
2887
|
-
return [{
|
|
2888
|
-
value: String(d.id),
|
|
2889
|
-
label: d.location !== null ? `${d.name} (${d.location})` : d.name
|
|
2890
|
-
}];
|
|
2891
|
-
});
|
|
2892
|
-
const trackedValue = resolveTrackedEventDeviceIds(blob, linkedIds).map(String);
|
|
2893
|
-
return { sections: [{
|
|
2894
|
-
id: "linked-devices",
|
|
2895
|
-
title: "Linked devices",
|
|
2896
|
-
description: "Companion devices associated with this camera. Device-tree children are always linked; everything else is the list you pick below. Devices in the same location are suggested first, never linked on their own (D356).",
|
|
2897
|
-
order: 70,
|
|
2898
|
-
fields: [{
|
|
2899
|
-
type: "device-multiselect",
|
|
2900
|
-
key: LINKED_IDS_CONFIG_KEY,
|
|
2901
|
-
label: "Linked devices",
|
|
2902
|
-
description: "Pick the companions of this camera. Same-location devices are suggested; device-tree children stay linked regardless.",
|
|
2903
|
-
deviceFilter: {
|
|
2904
|
-
excludeDeviceId: deviceId,
|
|
2905
|
-
excludeTypes: [DeviceType.Container]
|
|
2906
|
-
},
|
|
2907
|
-
suggestLocation: self?.location ?? null,
|
|
2908
|
-
value: config.manualIds.map(String)
|
|
2909
|
-
}, {
|
|
2910
|
-
type: "multiselect",
|
|
2911
|
-
key: LINKED_TRACKED_IDS_CONFIG_KEY,
|
|
2912
|
-
label: "Devices that produce tracked events",
|
|
2913
|
-
description: "Linked devices whose state changes materialize a synthetic tracked event on this camera. Default: none (opt-in).",
|
|
2914
|
-
options: trackedOptions,
|
|
2915
|
-
value: trackedValue
|
|
2916
|
-
}]
|
|
2917
|
-
}] };
|
|
2918
|
-
}
|
|
2919
|
-
/**
|
|
2920
|
-
* Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
|
|
2921
|
-
* The admin-ui save path reaches the device config blob via the
|
|
2922
|
-
* `writerCapName: 'device-manager'` routing (never this method), but the cap
|
|
2923
|
-
* contract includes the method — keep it correct for direct callers.
|
|
2887
|
+
* THE write path for the linked-devices keys — the `linked-devices` slot's
|
|
2888
|
+
* `set` lands here (D648). Normalizes the pick list and the tracked-event
|
|
2889
|
+
* opt-in; an EXPLICIT empty opt-in is stored as `[]`, never deleted, because
|
|
2890
|
+
* an absent key and an empty one are read alike today but only `[]` says the
|
|
2891
|
+
* operator chose "none".
|
|
2924
2892
|
*/
|
|
2925
2893
|
async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
|
|
2926
2894
|
const next = { ...await pctx.settings.readDeviceStore(deviceId) };
|
|
2927
2895
|
if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
|
|
2928
|
-
if ("_linkedDeviceTrackedIds" in patch)
|
|
2929
|
-
const raw = patch[LINKED_TRACKED_IDS_CONFIG_KEY];
|
|
2930
|
-
const ids = [];
|
|
2931
|
-
if (Array.isArray(raw)) for (const v of raw) {
|
|
2932
|
-
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
|
|
2933
|
-
if (Number.isInteger(n) && !ids.includes(n)) ids.push(n);
|
|
2934
|
-
}
|
|
2935
|
-
next[LINKED_TRACKED_IDS_CONFIG_KEY] = ids;
|
|
2936
|
-
}
|
|
2896
|
+
if ("_linkedDeviceTrackedIds" in patch) next[LINKED_TRACKED_IDS_CONFIG_KEY] = [...parseTrackedOptIn(patch)];
|
|
2937
2897
|
await pctx.settings.writeDeviceStore(deviceId, next);
|
|
2938
2898
|
return { success: true };
|
|
2939
2899
|
}
|
|
@@ -2948,11 +2908,8 @@ async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
|
|
|
2948
2908
|
* is not a new link. Removing them is the operator's call, never this
|
|
2949
2909
|
* function's (a missing source is shown, never silently dropped).
|
|
2950
2910
|
*
|
|
2951
|
-
*
|
|
2952
|
-
*
|
|
2953
|
-
* never refused, its picker never offers a container, and the Locations page
|
|
2954
|
-
* writes the list through it. Slice 1 changes no behaviour on the old path;
|
|
2955
|
-
* both paths persist through `applyLinkedDevicesPatch`.
|
|
2911
|
+
* Since D655 every write of the list goes through the slot, the Locations
|
|
2912
|
+
* page's room rebuild included, so this is the only gate a pick passes.
|
|
2956
2913
|
*/
|
|
2957
2914
|
function refusedLinkedPicks(params) {
|
|
2958
2915
|
const kept = new Set(params.persisted);
|
|
@@ -2979,43 +2936,6 @@ function refusedLinkedPicks(params) {
|
|
|
2979
2936
|
return out;
|
|
2980
2937
|
}
|
|
2981
2938
|
//#endregion
|
|
2982
|
-
//#region src/builtins/device-manager/device-store-sections.ts
|
|
2983
|
-
/** The device-manager's device-store-backed sections. Add an entry to extend. */
|
|
2984
|
-
var DEVICE_STORE_SECTIONS = [{
|
|
2985
|
-
keys: [
|
|
2986
|
-
LINKED_MODE_CONFIG_KEY,
|
|
2987
|
-
LINKED_IDS_CONFIG_KEY,
|
|
2988
|
-
LINKED_TRACKED_IDS_CONFIG_KEY
|
|
2989
|
-
],
|
|
2990
|
-
apply: (pctx, deviceId, patch) => applyLinkedDevicesPatch(pctx, deviceId, patch)
|
|
2991
|
-
}];
|
|
2992
|
-
/**
|
|
2993
|
-
* Split a `writerCapName: 'device-manager'` patch into device-store slices
|
|
2994
|
-
* (one per matching registered section) and the leftover driver patch.
|
|
2995
|
-
* Generic — consults {@link DEVICE_STORE_SECTIONS}, no hardcoded key list.
|
|
2996
|
-
*/
|
|
2997
|
-
function splitDeviceStoreKeys(patch) {
|
|
2998
|
-
const claimed = /* @__PURE__ */ new Set();
|
|
2999
|
-
const storeGroups = [];
|
|
3000
|
-
for (const section of DEVICE_STORE_SECTIONS) {
|
|
3001
|
-
const slice = {};
|
|
3002
|
-
for (const key of section.keys) if (key in patch) {
|
|
3003
|
-
slice[key] = patch[key];
|
|
3004
|
-
claimed.add(key);
|
|
3005
|
-
}
|
|
3006
|
-
if (Object.keys(slice).length > 0) storeGroups.push({
|
|
3007
|
-
section,
|
|
3008
|
-
patch: slice
|
|
3009
|
-
});
|
|
3010
|
-
}
|
|
3011
|
-
const driverPatch = {};
|
|
3012
|
-
for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
|
|
3013
|
-
return {
|
|
3014
|
-
storeGroups,
|
|
3015
|
-
driverPatch
|
|
3016
|
-
};
|
|
3017
|
-
}
|
|
3018
|
-
//#endregion
|
|
3019
2939
|
//#region src/builtins/device-manager/device-aggregation.ts
|
|
3020
2940
|
/**
|
|
3021
2941
|
* One process-wide cache behind every device-details fan-out.
|
|
@@ -3680,12 +3600,15 @@ async function updateDeviceFieldsBatch(deps, input) {
|
|
|
3680
3600
|
* registry lookup). Used by `updateDeviceFieldsBatch`. */
|
|
3681
3601
|
async function applyGroupPatch(deps, deviceId, group) {
|
|
3682
3602
|
if (group.writerCapName === "device-manager") {
|
|
3683
|
-
const
|
|
3684
|
-
if (
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3603
|
+
const retired = Object.keys(group.patch).filter((key) => LINKED_DEVICES_RESERVED_KEYS.has(key));
|
|
3604
|
+
if (retired.length > 0) {
|
|
3605
|
+
deps.ctx.logger.warn("device-manager: a save of the retired linked-devices section refused", {
|
|
3606
|
+
tags: { deviceId },
|
|
3607
|
+
meta: { keys: retired }
|
|
3608
|
+
});
|
|
3609
|
+
throw new Error(`[device-manager] ${retired.join(", ")} can no longer be saved from the settings section (D655) — reload the page and use the Extensions section`);
|
|
3688
3610
|
}
|
|
3611
|
+
const driverPatch = group.patch;
|
|
3689
3612
|
if (Object.keys(driverPatch).length === 0) return;
|
|
3690
3613
|
const hubRegistry = deps.ctx.kernel?.deviceRegistry;
|
|
3691
3614
|
const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
|
|
@@ -3974,12 +3897,11 @@ var DEVICE_MANAGER_EXTENSION_SOURCE = "device-manager";
|
|
|
3974
3897
|
/** The store the slot reads and writes, named (D62). */
|
|
3975
3898
|
var LINKED_DEVICES_AUTHORITY = `device-manager:${LINKED_IDS_CONFIG_KEY}`;
|
|
3976
3899
|
/**
|
|
3977
|
-
* The picked ids' ledger
|
|
3978
|
-
* store (`rows.getMany`) — never the whole fleet to label a handful of ids.
|
|
3900
|
+
* The picked ids' ledger fields, from ONE indexed read of the device manager's
|
|
3901
|
+
* own store (`rows.getMany`) — never the whole fleet to label a handful of ids.
|
|
3979
3902
|
* An id no device has is absent from the map.
|
|
3980
3903
|
*/
|
|
3981
|
-
|
|
3982
|
-
const rows = await pctx.metaStore.rows.getMany(ids);
|
|
3904
|
+
function pickedFrom(rows) {
|
|
3983
3905
|
const out = /* @__PURE__ */ new Map();
|
|
3984
3906
|
for (const [id, row] of rows) out.set(id, {
|
|
3985
3907
|
name: row.meta.name,
|
|
@@ -4008,6 +3930,78 @@ function sourceOf(deviceId, byId) {
|
|
|
4008
3930
|
health: "ok"
|
|
4009
3931
|
};
|
|
4010
3932
|
}
|
|
3933
|
+
/**
|
|
3934
|
+
* Which ids of a written opt-in must be refused: a NEW opt-in for a device the
|
|
3935
|
+
* camera does not link. An id already stored is never refused — whatever was
|
|
3936
|
+
* read can be written back unchanged (a stale opt-in is removed by leaving it
|
|
3937
|
+
* out, never by being unable to save). The written list REPLACES the stored
|
|
3938
|
+
* one: since D655 the editor shows every stored opt-in, so nothing it sends
|
|
3939
|
+
* back is written blind.
|
|
3940
|
+
*/
|
|
3941
|
+
function refusedTrackedOptIns(params) {
|
|
3942
|
+
const allowed = new Set([
|
|
3943
|
+
...params.picked,
|
|
3944
|
+
...params.implied,
|
|
3945
|
+
...params.stored
|
|
3946
|
+
]);
|
|
3947
|
+
return params.tracked.filter((id) => !allowed.has(id));
|
|
3948
|
+
}
|
|
3949
|
+
/** A ledger row as the pure link resolver reads it, or null for an unknown type. */
|
|
3950
|
+
function toLinkCandidate(row) {
|
|
3951
|
+
const type = Object.values(DeviceType).find((t) => t === row.meta.type);
|
|
3952
|
+
if (type === void 0) return null;
|
|
3953
|
+
return {
|
|
3954
|
+
id: row.meta.id,
|
|
3955
|
+
parentDeviceId: row.meta.parentDeviceId,
|
|
3956
|
+
location: row.meta.location,
|
|
3957
|
+
type,
|
|
3958
|
+
stableId: row.meta.stableId,
|
|
3959
|
+
...row.meta.primaryChildEntityId !== void 0 ? { primaryChildEntityId: row.meta.primaryChildEntityId } : {},
|
|
3960
|
+
...row.meta.role !== void 0 ? { role: row.meta.role } : {},
|
|
3961
|
+
name: row.meta.name
|
|
3962
|
+
};
|
|
3963
|
+
}
|
|
3964
|
+
/** How deep a container nesting is walked — a cycle in the ledger must end. */
|
|
3965
|
+
var MAX_CONTAINER_DEPTH = 8;
|
|
3966
|
+
/**
|
|
3967
|
+
* The devices the camera links BY STRUCTURE, labelled: what the resolver adds
|
|
3968
|
+
* beyond the picks. Computed by the resolver itself (`resolveLinkedDeviceIds`)
|
|
3969
|
+
* over the only rows it can reach — the picks, the camera's children, and the
|
|
3970
|
+
* descendants of every container among them — read level by level with ONE
|
|
3971
|
+
* indexed `listByParentMany` each, never the fleet.
|
|
3972
|
+
*/
|
|
3973
|
+
async function impliedLinks(pctx, deviceId, manualIds, pickedRows) {
|
|
3974
|
+
const rows = new Map(pickedRows);
|
|
3975
|
+
let parents = [deviceId, ...[...pickedRows.values()].filter((r) => r.meta.type === DeviceType.Container).map((r) => r.meta.id)];
|
|
3976
|
+
const walked = /* @__PURE__ */ new Set();
|
|
3977
|
+
for (let depth = 0; depth < MAX_CONTAINER_DEPTH && parents.length > 0; depth++) {
|
|
3978
|
+
for (const id of parents) walked.add(id);
|
|
3979
|
+
const byParent = await pctx.metaStore.rows.listByParentMany(parents);
|
|
3980
|
+
const next = [];
|
|
3981
|
+
for (const children of byParent.values()) for (const child of children) {
|
|
3982
|
+
rows.set(child.meta.id, child);
|
|
3983
|
+
if (child.meta.type === DeviceType.Container && !walked.has(child.meta.id)) next.push(child.meta.id);
|
|
3984
|
+
}
|
|
3985
|
+
parents = next;
|
|
3986
|
+
}
|
|
3987
|
+
const candidates = [...rows.values()].flatMap((r) => {
|
|
3988
|
+
const c = toLinkCandidate(r);
|
|
3989
|
+
return c === null ? [] : [c];
|
|
3990
|
+
});
|
|
3991
|
+
const picks = new Set(manualIds);
|
|
3992
|
+
return resolveLinkedDeviceIds({
|
|
3993
|
+
selfId: deviceId,
|
|
3994
|
+
config: {
|
|
3995
|
+
mode: "manual",
|
|
3996
|
+
manualIds
|
|
3997
|
+
},
|
|
3998
|
+
candidates
|
|
3999
|
+
}).filter((id) => !picks.has(id)).map((id) => ({
|
|
4000
|
+
deviceId: id,
|
|
4001
|
+
name: rows.get(id)?.meta.name ?? null,
|
|
4002
|
+
health: "ok"
|
|
4003
|
+
}));
|
|
4004
|
+
}
|
|
4011
4005
|
function describeRefusal(deviceId, reason) {
|
|
4012
4006
|
if (reason === "self") return `${deviceId} is the camera itself`;
|
|
4013
4007
|
if (reason === "container") return `${deviceId} is a container — pick its children`;
|
|
@@ -4045,21 +4039,27 @@ function createDeviceManagerExtensionProvider(pctx) {
|
|
|
4045
4039
|
label: "Device manager",
|
|
4046
4040
|
slots: [defineExtensionSlotProvider("linked-devices", {
|
|
4047
4041
|
list: async (deviceId) => {
|
|
4048
|
-
const
|
|
4049
|
-
const
|
|
4042
|
+
const blob = await pctx.settings.readDeviceStore(deviceId);
|
|
4043
|
+
const { manualIds } = parseLinkedDevicesConfig(blob);
|
|
4044
|
+
const pickedRows = await pctx.metaStore.rows.getMany(manualIds);
|
|
4045
|
+
const byId = pickedFrom(pickedRows);
|
|
4046
|
+
const implied = await impliedLinks(pctx, deviceId, manualIds, pickedRows);
|
|
4050
4047
|
return {
|
|
4051
4048
|
authority: LINKED_DEVICES_AUTHORITY,
|
|
4052
4049
|
enabled: null,
|
|
4053
4050
|
activation: null,
|
|
4054
4051
|
value: {
|
|
4055
4052
|
slot: "linked-devices",
|
|
4056
|
-
deviceIds: [...manualIds]
|
|
4053
|
+
deviceIds: [...manualIds],
|
|
4054
|
+
trackedDeviceIds: [...parseTrackedOptIn(blob)]
|
|
4057
4055
|
},
|
|
4058
|
-
sources: manualIds.map((id) => sourceOf(id, byId))
|
|
4056
|
+
sources: manualIds.map((id) => sourceOf(id, byId)),
|
|
4057
|
+
...implied.length > 0 ? { impliedSources: implied } : {}
|
|
4059
4058
|
};
|
|
4060
4059
|
},
|
|
4061
4060
|
set: async (deviceId, value) => {
|
|
4062
|
-
const [
|
|
4061
|
+
const [pickedRows, blob] = await Promise.all([pctx.metaStore.rows.getMany(value.deviceIds), pctx.settings.readDeviceStore(deviceId)]);
|
|
4062
|
+
const byId = pickedFrom(pickedRows);
|
|
4063
4063
|
const refused = refusedLinkedPicks({
|
|
4064
4064
|
selfId: deviceId,
|
|
4065
4065
|
picked: value.deviceIds,
|
|
@@ -4073,7 +4073,32 @@ function createDeviceManagerExtensionProvider(pctx) {
|
|
|
4073
4073
|
});
|
|
4074
4074
|
throw new Error(`linked-devices on ${deviceId}: refused — ` + refused.map((r) => describeRefusal(r.deviceId, r.reason)).join("; "));
|
|
4075
4075
|
}
|
|
4076
|
-
|
|
4076
|
+
const tracked = value.trackedDeviceIds;
|
|
4077
|
+
if (tracked === void 0) {
|
|
4078
|
+
await applyLinkedDevicesPatch(pctx, deviceId, { [LINKED_IDS_CONFIG_KEY]: value.deviceIds });
|
|
4079
|
+
return {
|
|
4080
|
+
applied: true,
|
|
4081
|
+
reload: "none"
|
|
4082
|
+
};
|
|
4083
|
+
}
|
|
4084
|
+
const implied = await impliedLinks(pctx, deviceId, value.deviceIds, pickedRows);
|
|
4085
|
+
const notLinked = refusedTrackedOptIns({
|
|
4086
|
+
tracked,
|
|
4087
|
+
picked: value.deviceIds,
|
|
4088
|
+
implied: implied.map((s) => s.deviceId),
|
|
4089
|
+
stored: parseTrackedOptIn(blob)
|
|
4090
|
+
});
|
|
4091
|
+
if (notLinked.length > 0) {
|
|
4092
|
+
pctx.host.ctx.logger.warn("linked-devices: write refused — nothing persisted", {
|
|
4093
|
+
tags: { deviceId },
|
|
4094
|
+
meta: { trackedNotLinked: notLinked }
|
|
4095
|
+
});
|
|
4096
|
+
throw new Error(`linked-devices on ${deviceId}: refused — tracked events can only be opted in for a linked device, not ${notLinked.join(", ")}`);
|
|
4097
|
+
}
|
|
4098
|
+
await applyLinkedDevicesPatch(pctx, deviceId, {
|
|
4099
|
+
[LINKED_IDS_CONFIG_KEY]: value.deviceIds,
|
|
4100
|
+
[LINKED_TRACKED_IDS_CONFIG_KEY]: [...new Set(tracked)]
|
|
4101
|
+
});
|
|
4077
4102
|
return {
|
|
4078
4103
|
applied: true,
|
|
4079
4104
|
reload: "none"
|
|
@@ -4255,9 +4280,9 @@ async function reloadMigratedDevices(pctx, targets) {
|
|
|
4255
4280
|
*
|
|
4256
4281
|
* - The DRIVER config keys (host, port, credentials, `deviceCache`, every
|
|
4257
4282
|
* schema field) SWAP — they describe the box.
|
|
4258
|
-
* - The device-manager-owned
|
|
4259
|
-
*
|
|
4260
|
-
* "the driveway camera", not to a serial number.
|
|
4283
|
+
* - The device-manager-owned linked-devices keys (mode, ids, tracked-event
|
|
4284
|
+
* opt-in — {@link ROLE_SCOPED_CONFIG_KEYS}) STAY with the number — a sensor
|
|
4285
|
+
* is linked to "the driveway camera", not to a serial number.
|
|
4261
4286
|
* - `_profileMap` is DROPPED from both. It is the legacy spelling of the
|
|
4262
4287
|
* stream-profile assignment (the live authority is the stream broker's own
|
|
4263
4288
|
* map — see `StreamsTab.tsx`, which stopped writing it), and either copy
|
|
@@ -4275,7 +4300,7 @@ async function reloadMigratedDevices(pctx, targets) {
|
|
|
4275
4300
|
/** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
|
|
4276
4301
|
var PROFILE_MAP_CONFIG_KEY = "_profileMap";
|
|
4277
4302
|
/** Config keys that belong to the camera's ROLE and stay with the number. */
|
|
4278
|
-
var ROLE_SCOPED_CONFIG_KEYS =
|
|
4303
|
+
var ROLE_SCOPED_CONFIG_KEYS = LINKED_DEVICES_RESERVED_KEYS;
|
|
4279
4304
|
function splitConfig(config) {
|
|
4280
4305
|
const hardware = {};
|
|
4281
4306
|
const role = {};
|
|
@@ -8062,9 +8087,6 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
8062
8087
|
getChildrenBatch: (input) => getChildrenBatch(pctx, input),
|
|
8063
8088
|
getLinkedDevices: (input) => getLinkedDevices(pctx, input),
|
|
8064
8089
|
getLinkedDevicesBatch: (input) => getLinkedDevicesBatch(pctx, input),
|
|
8065
|
-
getDeviceSettingsContribution: (input) => buildLinkedDevicesContribution(pctx, input.deviceId),
|
|
8066
|
-
getDeviceLiveContribution: async () => null,
|
|
8067
|
-
applyDeviceSettingsPatch: (input) => applyLinkedDevicesPatch(pctx, input.deviceId, input.patch),
|
|
8068
8090
|
getStreamSources: (input) => getStreamSources(pctx, input),
|
|
8069
8091
|
getConfigSchema: (input) => getConfigSchema(pctx, input),
|
|
8070
8092
|
getSettingsSchema: (input) => getSettingsSchema(pctx, input),
|
|
@@ -24,13 +24,19 @@
|
|
|
24
24
|
* not survive the next read. So a negative read ARMS the revocation and a
|
|
25
25
|
* second consecutive negative applies it.
|
|
26
26
|
*
|
|
27
|
-
* ERRING. `undefined` (never read) and `unbind-pending` both ring.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* ERRING. `undefined` (never read) and `unbind-pending` both ring. The cost
|
|
28
|
+
* of erring open is a spurious ring on a camera whose wrapper is not bound
|
|
29
|
+
* but which has trigger sources, for at most two reload cycles. The cost of
|
|
30
|
+
* erring closed is the press.
|
|
31
|
+
*
|
|
32
|
+
* D655: a configured source is NO LONGER evidence of the binding. D49 leaned
|
|
33
|
+
* on "a source can only be configured through this wrapper's own
|
|
34
|
+
* binding-gated settings surface"; that surface is retired, and the
|
|
35
|
+
* `doorbell-source` extension slot that replaced it is offered on every
|
|
36
|
+
* camera, bound or not (D648). The erring-open window above is therefore
|
|
37
|
+
* reachable by configuring sources on a never-bound camera, not only by
|
|
38
|
+
* unbinding a configured one — still bounded by two reload cycles, and the
|
|
39
|
+
* slot's card says `inactive` once the reads agree.
|
|
34
40
|
*
|
|
35
41
|
* ─────────────────────────────────────────────────────────────────────
|
|
36
42
|
* 2026-08-07 (D72). The corroboration above was DEFEATED, and the reason
|
|
@@ -5,7 +5,7 @@ interface VirtualDoorbellAddonConfig {
|
|
|
5
5
|
/**
|
|
6
6
|
* Reload the camera→source assignment map (and the binding mirror) at
|
|
7
7
|
* most this often — lazily, on trigger traffic. Covers devices/settings
|
|
8
|
-
* appearing after boot without a
|
|
8
|
+
* appearing after boot without a save, and is the cadence on
|
|
9
9
|
* which an unbind gets corroborated.
|
|
10
10
|
*/
|
|
11
11
|
readonly assignmentsTtlMs: number;
|
|
@@ -19,9 +19,9 @@ interface VirtualDoorbellAddonConfig {
|
|
|
19
19
|
*
|
|
20
20
|
* 1. The operator binds the wrapper to a camera in the bindings UI
|
|
21
21
|
* (`defaultActive: false` — never auto-bound).
|
|
22
|
-
* 2. In the camera's
|
|
23
|
-
* operator picks one or more SOURCE devices
|
|
24
|
-
*
|
|
22
|
+
* 2. In the camera's Extensions section (the `doorbell-source` slot,
|
|
23
|
+
* D648/D655) the operator picks one or more SOURCE devices — that is
|
|
24
|
+
* the ONLY choice. The reaction is derived automatically per
|
|
25
25
|
* device type: a binary sensor rings when it goes active, a switch
|
|
26
26
|
* rings when it turns on (see `trigger-engine.ts`). The persisted
|
|
27
27
|
* shape (`doorbellSources: [{deviceId}]`) stays extensible for a
|
|
@@ -54,8 +54,8 @@ export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddon
|
|
|
54
54
|
constructor();
|
|
55
55
|
protected onInitialize(): Promise<ProviderRegistration[]>;
|
|
56
56
|
/**
|
|
57
|
-
* The `device-extension` provider: the
|
|
58
|
-
*
|
|
57
|
+
* The `device-extension` provider: the only editor of a camera's trigger
|
|
58
|
+
* devices since D655 (see `doorbell-extension-slot.ts`).
|
|
59
59
|
*/
|
|
60
60
|
private buildExtensionProvider;
|
|
61
61
|
private handleStateChanged;
|
|
@@ -141,18 +141,11 @@ export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddon
|
|
|
141
141
|
* Returns `true` when the ring must proceed.
|
|
142
142
|
*/
|
|
143
143
|
private ringsDespiteUnboundMirror;
|
|
144
|
-
/**
|
|
145
|
-
* Record a binding we observed FIRST-HAND. Both callers are cap methods
|
|
146
|
-
* the router only reaches by resolving this wrapper for this device —
|
|
147
|
-
* which it can only do through the binding — so a call is direct evidence
|
|
148
|
-
* and outranks anything the store says.
|
|
149
|
-
*/
|
|
150
|
-
private confirmBindingFromCapCall;
|
|
151
144
|
/**
|
|
152
145
|
* Lazily (re)build the camera→source assignment set from every
|
|
153
146
|
* camera's device store. TTL-bounded so devices/settings appearing
|
|
154
|
-
* after boot are picked up without a
|
|
155
|
-
*
|
|
147
|
+
* after boot are picked up without a save; invalidated eagerly by
|
|
148
|
+
* `persistDoorbellSources`.
|
|
156
149
|
*/
|
|
157
150
|
private ensureAssignments;
|
|
158
151
|
private loadAssignments;
|
|
@@ -169,36 +162,10 @@ export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddon
|
|
|
169
162
|
*/
|
|
170
163
|
private readDeviceSources;
|
|
171
164
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*/
|
|
177
|
-
private lookupDevice;
|
|
178
|
-
/**
|
|
179
|
-
* Settings section for the device-detail page — contributed into the
|
|
180
|
-
* GENERAL settings surface (no `tab`, so it lands in the same tab as
|
|
181
|
-
* "Linked devices", ordered just after it). Camera-only: the doorbell
|
|
182
|
-
* cap also applies to Button accessories (native providers) where a
|
|
183
|
-
* virtual-source picker is meaningless.
|
|
184
|
-
*
|
|
185
|
-
* A SINGLE `device-multiselect` field carries only a declarative
|
|
186
|
-
* `deviceFilter` — the admin-ui renders the SHARED searchable device
|
|
187
|
-
* picker (same idiom as "Linked devices") whose options come from
|
|
188
|
-
* `deviceManager.listAll` filtered CLIENT-SIDE. The addon NO LONGER
|
|
189
|
-
* computes the option list server-side: the old path enumerated
|
|
190
|
-
* `deviceManager.getAllBindings`, which returns an empty set on the live
|
|
191
|
-
* cluster and left the picker blank. The reaction is still derived
|
|
192
|
-
* automatically per device type — no trigger/eventType controls.
|
|
193
|
-
*/
|
|
194
|
-
private buildDeviceSettingsContribution;
|
|
195
|
-
private saveDeviceSettingsPatch;
|
|
196
|
-
/**
|
|
197
|
-
* THE write path for a camera's trigger sources — the settings save above
|
|
198
|
-
* and the `doorbell-source` extension slot both land here (D648), so the
|
|
199
|
-
* two surfaces cannot store different shapes: normalise to
|
|
200
|
-
* `doorbellSources: [{deviceId}]`, strip the legacy keys, and invalidate the
|
|
201
|
-
* assignment cache so the next rise (and the binding mirror's next
|
|
165
|
+
* THE write path for a camera's trigger sources — the `doorbell-source`
|
|
166
|
+
* extension slot lands here (D648; its only caller since D655): normalise
|
|
167
|
+
* to `doorbellSources: [{deviceId}]`, strip the legacy keys, and invalidate
|
|
168
|
+
* the assignment cache so the next rise (and the binding mirror's next
|
|
202
169
|
* refresh, off the ring path) sees the new wiring.
|
|
203
170
|
*/
|
|
204
171
|
private persistDoorbellSources;
|