@camstack/addon-provider-homeassistant 1.2.65 → 1.2.66
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.
|
@@ -20,17 +20,54 @@ let node_crypto = require("node:crypto");
|
|
|
20
20
|
*/
|
|
21
21
|
/** Root of every topic. Namespaces the export inside HA's event bus. */
|
|
22
22
|
var TOPIC_ROOT = "camstack";
|
|
23
|
-
/** Prefix that turns a device's
|
|
23
|
+
/** Prefix that turns a device's numeric id into its HA `device_id`. */
|
|
24
24
|
var DEVICE_KEY_PREFIX = "camstack-";
|
|
25
25
|
/**
|
|
26
|
-
* The HA `device_id` for a camstack device.
|
|
27
|
-
*
|
|
28
|
-
* Derived from
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* The HA `device_id` for a camstack device. **THIS FILE OWNS THE RULE.**
|
|
27
|
+
*
|
|
28
|
+
* Derived from the NUMERIC device id, which is what the other three
|
|
29
|
+
* ecosystem exports already key on: Alexa's `endpointId` is
|
|
30
|
+
* `String(deviceId)`, HomeKit's accessory uuid is
|
|
31
|
+
* `camstack:camera:<deviceId>` and Google's `id` is `String(deviceId)`.
|
|
32
|
+
* Home Assistant was the only export keying on `stableId`, and `stableId`
|
|
33
|
+
* is provider-internal — `(addonId, stableId)` is the addon-facing
|
|
34
|
+
* identity in `device-meta-actions.ts`, not an identity meant to leave the
|
|
35
|
+
* hub. This was the one place it leaked.
|
|
36
|
+
*
|
|
37
|
+
* The rationale that used to sit here — "numeric ids are reallocated by a
|
|
38
|
+
* re-sync, so a captured number would address whichever camera inherited
|
|
39
|
+
* it" — was FALSE and is deleted rather than softened.
|
|
40
|
+
* `allocateNextDeviceId` (`@camstack/system`,
|
|
41
|
+
* `device-meta-store.ts`) is a monotonic counter: it reads
|
|
42
|
+
* `nextDeviceId ?? 1` and writes `current + 1`. A freed id is never
|
|
43
|
+
* re-issued. The worst case after a re-adoption is an ORPHANED entity —
|
|
44
|
+
* never a mis-addressed one — which is exactly what a changed `stableId`
|
|
45
|
+
* produced too.
|
|
46
|
+
*
|
|
47
|
+
* The component MIRRORS this rule in `CamStackDevice.device_key`
|
|
48
|
+
* (`custom_components/camstack/coordinator.py`), because the camera entity
|
|
49
|
+
* is built from the export membership rather than pushed. Everywhere the
|
|
50
|
+
* component receives a key it takes it verbatim (`dev.ids[0]` /
|
|
51
|
+
* `entity_change.device_id`) instead of composing one. The two
|
|
52
|
+
* compositions are pinned to the same literal by
|
|
53
|
+
* `__tests__/entity-catalog.spec.ts` ("the cross-side identity contract")
|
|
54
|
+
* and `tests/test_entities.py::test_the_device_key_matches_the_hub_rule`.
|
|
55
|
+
*/
|
|
56
|
+
function deviceKeyFor(deviceId) {
|
|
57
|
+
return `${DEVICE_KEY_PREFIX}${deviceId}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The HA `device_id` for a SYNTHETIC device.
|
|
61
|
+
*
|
|
62
|
+
* Synthetic devices are built by the exporter and have no camstack device
|
|
63
|
+
* behind them — `HaDevicePlan.deviceId` is `null` for one — so there is no
|
|
64
|
+
* numeric id to key on and their own `synthetic-…` id IS their identity.
|
|
65
|
+
* A separate builder rather than an overload: a caller that has a real
|
|
66
|
+
* device must not be able to reach the string-keyed path by accident,
|
|
67
|
+
* which is how a second HA device appears beside the first.
|
|
31
68
|
*/
|
|
32
|
-
function
|
|
33
|
-
return `${DEVICE_KEY_PREFIX}${
|
|
69
|
+
function syntheticDeviceKeyFor(syntheticId) {
|
|
70
|
+
return `${DEVICE_KEY_PREFIX}${syntheticId}`;
|
|
34
71
|
}
|
|
35
72
|
function stateTopic(deviceKey, entity) {
|
|
36
73
|
return `${TOPIC_ROOT}/${deviceKey}/${entity}`;
|
|
@@ -121,7 +158,7 @@ function toSlug(value) {
|
|
|
121
158
|
* behind two url gates: a refusal for a credential in the authority and a
|
|
122
159
|
* refusal for a loopback host. On 2026-08-14 the live registry was read:
|
|
123
160
|
* `platform: camstack` held **165 entities and exactly two `camera` ones**
|
|
124
|
-
* — one per exported camera, `unique_id` `camstack_<
|
|
161
|
+
* — one per exported camera, `unique_id` `camstack_<deviceId>_camera`.
|
|
125
162
|
* None of the twelve announced stream components existed.
|
|
126
163
|
*
|
|
127
164
|
* They never could. `custom_components/camstack/camera.py` builds its
|
|
@@ -146,8 +183,9 @@ function toSlug(value) {
|
|
|
146
183
|
*
|
|
147
184
|
* The component's membership entity IS the adaptive stream: it negotiates
|
|
148
185
|
* no target, it is the device's primary camera (`_attr_name = None`), and
|
|
149
|
-
* it
|
|
150
|
-
*
|
|
186
|
+
* it carries `camstack_<deviceId>_camera` — the component builds that one
|
|
187
|
+
* itself, from the export membership, mirroring `deviceKeyFor`'s numeric
|
|
188
|
+
* rule. Announcing a component for it would claim the same
|
|
151
189
|
* `unique_id` — Home Assistant keys its registry on
|
|
152
190
|
* `(platform, integration, unique_id)` — and one of the two would lose the
|
|
153
191
|
* race and be dropped with a "does not generate unique IDs" error. So the
|
|
@@ -909,7 +947,7 @@ function buildNativeComponent(input) {
|
|
|
909
947
|
return {
|
|
910
948
|
component: {
|
|
911
949
|
platform: native.platform,
|
|
912
|
-
unique_id: `${input.
|
|
950
|
+
unique_id: `${input.deviceId}_${derivedEntityId(input.capName)}`,
|
|
913
951
|
name: input.label,
|
|
914
952
|
...primary.control,
|
|
915
953
|
...native.deviceClass !== void 0 ? { device_class: native.deviceClass } : {},
|
|
@@ -1136,10 +1174,10 @@ var MIRED_RANGE = {
|
|
|
1136
1174
|
step: 1
|
|
1137
1175
|
};
|
|
1138
1176
|
function buildComponent(device, spec) {
|
|
1139
|
-
const deviceKey = deviceKeyFor(device.
|
|
1177
|
+
const deviceKey = deviceKeyFor(device.id);
|
|
1140
1178
|
return {
|
|
1141
1179
|
platform: spec.platform,
|
|
1142
|
-
unique_id: `${device.
|
|
1180
|
+
unique_id: `${device.id}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
1143
1181
|
name: spec.label,
|
|
1144
1182
|
...STATELESS_PLATFORMS.has(spec.platform) ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
1145
1183
|
...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
@@ -1164,7 +1202,7 @@ function buildComponent(device, spec) {
|
|
|
1164
1202
|
}
|
|
1165
1203
|
function deviceBlock(device) {
|
|
1166
1204
|
return {
|
|
1167
|
-
ids: [deviceKeyFor(device.
|
|
1205
|
+
ids: [deviceKeyFor(device.id)],
|
|
1168
1206
|
name: device.name,
|
|
1169
1207
|
mf: device.manufacturer ?? "CamStack",
|
|
1170
1208
|
mdl: device.model ?? device.type
|
|
@@ -1542,7 +1580,7 @@ function assemble(device, specs) {
|
|
|
1542
1580
|
const cmps = {};
|
|
1543
1581
|
for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = buildComponent(device, spec);
|
|
1544
1582
|
return {
|
|
1545
|
-
deviceKey: deviceKeyFor(device.
|
|
1583
|
+
deviceKey: deviceKeyFor(device.id),
|
|
1546
1584
|
deviceId: device.id,
|
|
1547
1585
|
dev: deviceBlock(device),
|
|
1548
1586
|
cmps
|
|
@@ -2916,8 +2954,8 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
|
2916
2954
|
const mapping = CAP_ENTITY_MAP[cap];
|
|
2917
2955
|
const platform = nativePlatformFor(cap);
|
|
2918
2956
|
const plan = mapping === void 0 || platform === null || !options.platforms.has(platform) ? null : buildNativeComponent({
|
|
2919
|
-
deviceKey: deviceKeyFor(device.
|
|
2920
|
-
|
|
2957
|
+
deviceKey: deviceKeyFor(device.id),
|
|
2958
|
+
deviceId: device.id,
|
|
2921
2959
|
capName: cap,
|
|
2922
2960
|
label: humanise(cap),
|
|
2923
2961
|
slice: device.capSlices?.[cap],
|
|
@@ -5127,9 +5165,13 @@ function cameraStatusToken(input) {
|
|
|
5127
5165
|
* restart.
|
|
5128
5166
|
*
|
|
5129
5167
|
* The identity rules are the same wire format as every other device
|
|
5130
|
-
* (`../CLAUDE.md`)
|
|
5131
|
-
*
|
|
5132
|
-
*
|
|
5168
|
+
* (`../CLAUDE.md`), with the one difference the category forces: a real device
|
|
5169
|
+
* is keyed on its NUMERIC id (`device_id` `camstack-<deviceId>`, `unique_id`
|
|
5170
|
+
* `<deviceId>_<entity>`) and these have no numeric id at all, so their own
|
|
5171
|
+
* `synthetic-…` id is their identity — `device_id` is `camstack-<syntheticId>`
|
|
5172
|
+
* and `unique_id` is `<syntheticId>_<entity>`, built through
|
|
5173
|
+
* {@link syntheticDeviceKeyFor}. The ids are prefixed `synthetic-` so they can
|
|
5174
|
+
* never collide with a numeric key, whatever the counter reaches.
|
|
5133
5175
|
*/
|
|
5134
5176
|
/** The stable id of the notification-centre device. A WIRE FORMAT. */
|
|
5135
5177
|
var NOTIFICATION_CENTER_STABLE_ID = "synthetic-notification-center";
|
|
@@ -5138,7 +5180,7 @@ var SERVER_STABLE_ID = "synthetic-server";
|
|
|
5138
5180
|
var SYNTHETIC_STABLE_IDS = [NOTIFICATION_CENTER_STABLE_ID, SERVER_STABLE_ID];
|
|
5139
5181
|
/** Is this device key one of ours? Used by the command route. */
|
|
5140
5182
|
function isSyntheticDeviceKey(deviceKey) {
|
|
5141
|
-
return SYNTHETIC_STABLE_IDS.some((id) =>
|
|
5183
|
+
return SYNTHETIC_STABLE_IDS.some((id) => syntheticDeviceKeyFor(id) === deviceKey);
|
|
5142
5184
|
}
|
|
5143
5185
|
/**
|
|
5144
5186
|
* Pure derivation of the three alert entities (Task D1) — the conditions
|
|
@@ -5168,12 +5210,12 @@ function deriveAlertSummary(alerts) {
|
|
|
5168
5210
|
* a rule they want to reach. Shipping them disabled would repeat the bug that
|
|
5169
5211
|
* exported a thermometer's temperature switched off.
|
|
5170
5212
|
*/
|
|
5171
|
-
function buildComponents(
|
|
5172
|
-
const deviceKey =
|
|
5213
|
+
function buildComponents(syntheticId, specs) {
|
|
5214
|
+
const deviceKey = syntheticDeviceKeyFor(syntheticId);
|
|
5173
5215
|
const cmps = {};
|
|
5174
5216
|
for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = {
|
|
5175
5217
|
platform: spec.platform,
|
|
5176
|
-
unique_id: `${
|
|
5218
|
+
unique_id: `${syntheticId}_${spec.entity}`,
|
|
5177
5219
|
name: spec.label,
|
|
5178
5220
|
...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
5179
5221
|
...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
@@ -5189,17 +5231,17 @@ function buildComponents(stableId, specs) {
|
|
|
5189
5231
|
};
|
|
5190
5232
|
return cmps;
|
|
5191
5233
|
}
|
|
5192
|
-
function plan(
|
|
5234
|
+
function plan(syntheticId, name, specs) {
|
|
5193
5235
|
return {
|
|
5194
|
-
deviceKey:
|
|
5236
|
+
deviceKey: syntheticDeviceKeyFor(syntheticId),
|
|
5195
5237
|
deviceId: null,
|
|
5196
5238
|
dev: {
|
|
5197
|
-
ids: [
|
|
5239
|
+
ids: [syntheticDeviceKeyFor(syntheticId)],
|
|
5198
5240
|
name,
|
|
5199
5241
|
mf: "CamStack",
|
|
5200
5242
|
mdl: "Synthetic"
|
|
5201
5243
|
},
|
|
5202
|
-
cmps: buildComponents(
|
|
5244
|
+
cmps: buildComponents(syntheticId, specs)
|
|
5203
5245
|
};
|
|
5204
5246
|
}
|
|
5205
5247
|
/**
|
|
@@ -5570,8 +5612,8 @@ function projectTriggerFacts(deviceKey, entityFor, t) {
|
|
|
5570
5612
|
* whatever it was 300 seconds ago and read as a stalled machine.
|
|
5571
5613
|
*/
|
|
5572
5614
|
function projectSynthetic(input, nowMs) {
|
|
5573
|
-
const nc =
|
|
5574
|
-
const srv =
|
|
5615
|
+
const nc = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
5616
|
+
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5575
5617
|
const online = input.nodes.filter((node) => node.online);
|
|
5576
5618
|
const values = [
|
|
5577
5619
|
{
|
|
@@ -6537,7 +6579,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6537
6579
|
* a camera's `snooze` select uses, over the SAME `snoozes` read this
|
|
6538
6580
|
* pass already made — one expression of the rule, not two.
|
|
6539
6581
|
*/
|
|
6540
|
-
const ncKey =
|
|
6582
|
+
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
6541
6583
|
await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
|
|
6542
6584
|
}
|
|
6543
6585
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
@@ -6929,7 +6971,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6929
6971
|
const model = readString(device.metadata ?? {}, "model");
|
|
6930
6972
|
return {
|
|
6931
6973
|
id: device.id,
|
|
6932
|
-
stableId: device.stableId,
|
|
6933
6974
|
name: device.name,
|
|
6934
6975
|
type: device.type,
|
|
6935
6976
|
...manufacturer !== null ? { manufacturer } : {},
|
|
@@ -7393,7 +7434,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7393
7434
|
* `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
|
|
7394
7435
|
* store of its own.
|
|
7395
7436
|
*/
|
|
7396
|
-
if (parsed.deviceKey ===
|
|
7437
|
+
if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
|
|
7397
7438
|
if (!SNOOZE_OPTIONS.includes(value)) {
|
|
7398
7439
|
this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
|
|
7399
7440
|
topic,
|
|
@@ -7679,7 +7720,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7679
7720
|
async republishGlobalSnooze() {
|
|
7680
7721
|
try {
|
|
7681
7722
|
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7682
|
-
const ncKey =
|
|
7723
|
+
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
7683
7724
|
await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
|
|
7684
7725
|
} catch (err) {
|
|
7685
7726
|
this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
|
|
@@ -15,17 +15,54 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
15
15
|
*/
|
|
16
16
|
/** Root of every topic. Namespaces the export inside HA's event bus. */
|
|
17
17
|
var TOPIC_ROOT = "camstack";
|
|
18
|
-
/** Prefix that turns a device's
|
|
18
|
+
/** Prefix that turns a device's numeric id into its HA `device_id`. */
|
|
19
19
|
var DEVICE_KEY_PREFIX = "camstack-";
|
|
20
20
|
/**
|
|
21
|
-
* The HA `device_id` for a camstack device.
|
|
22
|
-
*
|
|
23
|
-
* Derived from
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* The HA `device_id` for a camstack device. **THIS FILE OWNS THE RULE.**
|
|
22
|
+
*
|
|
23
|
+
* Derived from the NUMERIC device id, which is what the other three
|
|
24
|
+
* ecosystem exports already key on: Alexa's `endpointId` is
|
|
25
|
+
* `String(deviceId)`, HomeKit's accessory uuid is
|
|
26
|
+
* `camstack:camera:<deviceId>` and Google's `id` is `String(deviceId)`.
|
|
27
|
+
* Home Assistant was the only export keying on `stableId`, and `stableId`
|
|
28
|
+
* is provider-internal — `(addonId, stableId)` is the addon-facing
|
|
29
|
+
* identity in `device-meta-actions.ts`, not an identity meant to leave the
|
|
30
|
+
* hub. This was the one place it leaked.
|
|
31
|
+
*
|
|
32
|
+
* The rationale that used to sit here — "numeric ids are reallocated by a
|
|
33
|
+
* re-sync, so a captured number would address whichever camera inherited
|
|
34
|
+
* it" — was FALSE and is deleted rather than softened.
|
|
35
|
+
* `allocateNextDeviceId` (`@camstack/system`,
|
|
36
|
+
* `device-meta-store.ts`) is a monotonic counter: it reads
|
|
37
|
+
* `nextDeviceId ?? 1` and writes `current + 1`. A freed id is never
|
|
38
|
+
* re-issued. The worst case after a re-adoption is an ORPHANED entity —
|
|
39
|
+
* never a mis-addressed one — which is exactly what a changed `stableId`
|
|
40
|
+
* produced too.
|
|
41
|
+
*
|
|
42
|
+
* The component MIRRORS this rule in `CamStackDevice.device_key`
|
|
43
|
+
* (`custom_components/camstack/coordinator.py`), because the camera entity
|
|
44
|
+
* is built from the export membership rather than pushed. Everywhere the
|
|
45
|
+
* component receives a key it takes it verbatim (`dev.ids[0]` /
|
|
46
|
+
* `entity_change.device_id`) instead of composing one. The two
|
|
47
|
+
* compositions are pinned to the same literal by
|
|
48
|
+
* `__tests__/entity-catalog.spec.ts` ("the cross-side identity contract")
|
|
49
|
+
* and `tests/test_entities.py::test_the_device_key_matches_the_hub_rule`.
|
|
50
|
+
*/
|
|
51
|
+
function deviceKeyFor(deviceId) {
|
|
52
|
+
return `${DEVICE_KEY_PREFIX}${deviceId}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The HA `device_id` for a SYNTHETIC device.
|
|
56
|
+
*
|
|
57
|
+
* Synthetic devices are built by the exporter and have no camstack device
|
|
58
|
+
* behind them — `HaDevicePlan.deviceId` is `null` for one — so there is no
|
|
59
|
+
* numeric id to key on and their own `synthetic-…` id IS their identity.
|
|
60
|
+
* A separate builder rather than an overload: a caller that has a real
|
|
61
|
+
* device must not be able to reach the string-keyed path by accident,
|
|
62
|
+
* which is how a second HA device appears beside the first.
|
|
26
63
|
*/
|
|
27
|
-
function
|
|
28
|
-
return `${DEVICE_KEY_PREFIX}${
|
|
64
|
+
function syntheticDeviceKeyFor(syntheticId) {
|
|
65
|
+
return `${DEVICE_KEY_PREFIX}${syntheticId}`;
|
|
29
66
|
}
|
|
30
67
|
function stateTopic(deviceKey, entity) {
|
|
31
68
|
return `${TOPIC_ROOT}/${deviceKey}/${entity}`;
|
|
@@ -116,7 +153,7 @@ function toSlug(value) {
|
|
|
116
153
|
* behind two url gates: a refusal for a credential in the authority and a
|
|
117
154
|
* refusal for a loopback host. On 2026-08-14 the live registry was read:
|
|
118
155
|
* `platform: camstack` held **165 entities and exactly two `camera` ones**
|
|
119
|
-
* — one per exported camera, `unique_id` `camstack_<
|
|
156
|
+
* — one per exported camera, `unique_id` `camstack_<deviceId>_camera`.
|
|
120
157
|
* None of the twelve announced stream components existed.
|
|
121
158
|
*
|
|
122
159
|
* They never could. `custom_components/camstack/camera.py` builds its
|
|
@@ -141,8 +178,9 @@ function toSlug(value) {
|
|
|
141
178
|
*
|
|
142
179
|
* The component's membership entity IS the adaptive stream: it negotiates
|
|
143
180
|
* no target, it is the device's primary camera (`_attr_name = None`), and
|
|
144
|
-
* it
|
|
145
|
-
*
|
|
181
|
+
* it carries `camstack_<deviceId>_camera` — the component builds that one
|
|
182
|
+
* itself, from the export membership, mirroring `deviceKeyFor`'s numeric
|
|
183
|
+
* rule. Announcing a component for it would claim the same
|
|
146
184
|
* `unique_id` — Home Assistant keys its registry on
|
|
147
185
|
* `(platform, integration, unique_id)` — and one of the two would lose the
|
|
148
186
|
* race and be dropped with a "does not generate unique IDs" error. So the
|
|
@@ -904,7 +942,7 @@ function buildNativeComponent(input) {
|
|
|
904
942
|
return {
|
|
905
943
|
component: {
|
|
906
944
|
platform: native.platform,
|
|
907
|
-
unique_id: `${input.
|
|
945
|
+
unique_id: `${input.deviceId}_${derivedEntityId(input.capName)}`,
|
|
908
946
|
name: input.label,
|
|
909
947
|
...primary.control,
|
|
910
948
|
...native.deviceClass !== void 0 ? { device_class: native.deviceClass } : {},
|
|
@@ -1131,10 +1169,10 @@ var MIRED_RANGE = {
|
|
|
1131
1169
|
step: 1
|
|
1132
1170
|
};
|
|
1133
1171
|
function buildComponent(device, spec) {
|
|
1134
|
-
const deviceKey = deviceKeyFor(device.
|
|
1172
|
+
const deviceKey = deviceKeyFor(device.id);
|
|
1135
1173
|
return {
|
|
1136
1174
|
platform: spec.platform,
|
|
1137
|
-
unique_id: `${device.
|
|
1175
|
+
unique_id: `${device.id}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
1138
1176
|
name: spec.label,
|
|
1139
1177
|
...STATELESS_PLATFORMS.has(spec.platform) ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
1140
1178
|
...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
@@ -1159,7 +1197,7 @@ function buildComponent(device, spec) {
|
|
|
1159
1197
|
}
|
|
1160
1198
|
function deviceBlock(device) {
|
|
1161
1199
|
return {
|
|
1162
|
-
ids: [deviceKeyFor(device.
|
|
1200
|
+
ids: [deviceKeyFor(device.id)],
|
|
1163
1201
|
name: device.name,
|
|
1164
1202
|
mf: device.manufacturer ?? "CamStack",
|
|
1165
1203
|
mdl: device.model ?? device.type
|
|
@@ -1537,7 +1575,7 @@ function assemble(device, specs) {
|
|
|
1537
1575
|
const cmps = {};
|
|
1538
1576
|
for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = buildComponent(device, spec);
|
|
1539
1577
|
return {
|
|
1540
|
-
deviceKey: deviceKeyFor(device.
|
|
1578
|
+
deviceKey: deviceKeyFor(device.id),
|
|
1541
1579
|
deviceId: device.id,
|
|
1542
1580
|
dev: deviceBlock(device),
|
|
1543
1581
|
cmps
|
|
@@ -2911,8 +2949,8 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
|
2911
2949
|
const mapping = CAP_ENTITY_MAP[cap];
|
|
2912
2950
|
const platform = nativePlatformFor(cap);
|
|
2913
2951
|
const plan = mapping === void 0 || platform === null || !options.platforms.has(platform) ? null : buildNativeComponent({
|
|
2914
|
-
deviceKey: deviceKeyFor(device.
|
|
2915
|
-
|
|
2952
|
+
deviceKey: deviceKeyFor(device.id),
|
|
2953
|
+
deviceId: device.id,
|
|
2916
2954
|
capName: cap,
|
|
2917
2955
|
label: humanise(cap),
|
|
2918
2956
|
slice: device.capSlices?.[cap],
|
|
@@ -5122,9 +5160,13 @@ function cameraStatusToken(input) {
|
|
|
5122
5160
|
* restart.
|
|
5123
5161
|
*
|
|
5124
5162
|
* The identity rules are the same wire format as every other device
|
|
5125
|
-
* (`../CLAUDE.md`)
|
|
5126
|
-
*
|
|
5127
|
-
*
|
|
5163
|
+
* (`../CLAUDE.md`), with the one difference the category forces: a real device
|
|
5164
|
+
* is keyed on its NUMERIC id (`device_id` `camstack-<deviceId>`, `unique_id`
|
|
5165
|
+
* `<deviceId>_<entity>`) and these have no numeric id at all, so their own
|
|
5166
|
+
* `synthetic-…` id is their identity — `device_id` is `camstack-<syntheticId>`
|
|
5167
|
+
* and `unique_id` is `<syntheticId>_<entity>`, built through
|
|
5168
|
+
* {@link syntheticDeviceKeyFor}. The ids are prefixed `synthetic-` so they can
|
|
5169
|
+
* never collide with a numeric key, whatever the counter reaches.
|
|
5128
5170
|
*/
|
|
5129
5171
|
/** The stable id of the notification-centre device. A WIRE FORMAT. */
|
|
5130
5172
|
var NOTIFICATION_CENTER_STABLE_ID = "synthetic-notification-center";
|
|
@@ -5133,7 +5175,7 @@ var SERVER_STABLE_ID = "synthetic-server";
|
|
|
5133
5175
|
var SYNTHETIC_STABLE_IDS = [NOTIFICATION_CENTER_STABLE_ID, SERVER_STABLE_ID];
|
|
5134
5176
|
/** Is this device key one of ours? Used by the command route. */
|
|
5135
5177
|
function isSyntheticDeviceKey(deviceKey) {
|
|
5136
|
-
return SYNTHETIC_STABLE_IDS.some((id) =>
|
|
5178
|
+
return SYNTHETIC_STABLE_IDS.some((id) => syntheticDeviceKeyFor(id) === deviceKey);
|
|
5137
5179
|
}
|
|
5138
5180
|
/**
|
|
5139
5181
|
* Pure derivation of the three alert entities (Task D1) — the conditions
|
|
@@ -5163,12 +5205,12 @@ function deriveAlertSummary(alerts) {
|
|
|
5163
5205
|
* a rule they want to reach. Shipping them disabled would repeat the bug that
|
|
5164
5206
|
* exported a thermometer's temperature switched off.
|
|
5165
5207
|
*/
|
|
5166
|
-
function buildComponents(
|
|
5167
|
-
const deviceKey =
|
|
5208
|
+
function buildComponents(syntheticId, specs) {
|
|
5209
|
+
const deviceKey = syntheticDeviceKeyFor(syntheticId);
|
|
5168
5210
|
const cmps = {};
|
|
5169
5211
|
for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = {
|
|
5170
5212
|
platform: spec.platform,
|
|
5171
|
-
unique_id: `${
|
|
5213
|
+
unique_id: `${syntheticId}_${spec.entity}`,
|
|
5172
5214
|
name: spec.label,
|
|
5173
5215
|
...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
5174
5216
|
...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
@@ -5184,17 +5226,17 @@ function buildComponents(stableId, specs) {
|
|
|
5184
5226
|
};
|
|
5185
5227
|
return cmps;
|
|
5186
5228
|
}
|
|
5187
|
-
function plan(
|
|
5229
|
+
function plan(syntheticId, name, specs) {
|
|
5188
5230
|
return {
|
|
5189
|
-
deviceKey:
|
|
5231
|
+
deviceKey: syntheticDeviceKeyFor(syntheticId),
|
|
5190
5232
|
deviceId: null,
|
|
5191
5233
|
dev: {
|
|
5192
|
-
ids: [
|
|
5234
|
+
ids: [syntheticDeviceKeyFor(syntheticId)],
|
|
5193
5235
|
name,
|
|
5194
5236
|
mf: "CamStack",
|
|
5195
5237
|
mdl: "Synthetic"
|
|
5196
5238
|
},
|
|
5197
|
-
cmps: buildComponents(
|
|
5239
|
+
cmps: buildComponents(syntheticId, specs)
|
|
5198
5240
|
};
|
|
5199
5241
|
}
|
|
5200
5242
|
/**
|
|
@@ -5565,8 +5607,8 @@ function projectTriggerFacts(deviceKey, entityFor, t) {
|
|
|
5565
5607
|
* whatever it was 300 seconds ago and read as a stalled machine.
|
|
5566
5608
|
*/
|
|
5567
5609
|
function projectSynthetic(input, nowMs) {
|
|
5568
|
-
const nc =
|
|
5569
|
-
const srv =
|
|
5610
|
+
const nc = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
5611
|
+
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5570
5612
|
const online = input.nodes.filter((node) => node.online);
|
|
5571
5613
|
const values = [
|
|
5572
5614
|
{
|
|
@@ -6532,7 +6574,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6532
6574
|
* a camera's `snooze` select uses, over the SAME `snoozes` read this
|
|
6533
6575
|
* pass already made — one expression of the rule, not two.
|
|
6534
6576
|
*/
|
|
6535
|
-
const ncKey =
|
|
6577
|
+
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
6536
6578
|
await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
|
|
6537
6579
|
}
|
|
6538
6580
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
@@ -6924,7 +6966,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6924
6966
|
const model = readString(device.metadata ?? {}, "model");
|
|
6925
6967
|
return {
|
|
6926
6968
|
id: device.id,
|
|
6927
|
-
stableId: device.stableId,
|
|
6928
6969
|
name: device.name,
|
|
6929
6970
|
type: device.type,
|
|
6930
6971
|
...manufacturer !== null ? { manufacturer } : {},
|
|
@@ -7388,7 +7429,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7388
7429
|
* `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
|
|
7389
7430
|
* store of its own.
|
|
7390
7431
|
*/
|
|
7391
|
-
if (parsed.deviceKey ===
|
|
7432
|
+
if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
|
|
7392
7433
|
if (!SNOOZE_OPTIONS.includes(value)) {
|
|
7393
7434
|
this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
|
|
7394
7435
|
topic,
|
|
@@ -7674,7 +7715,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7674
7715
|
async republishGlobalSnooze() {
|
|
7675
7716
|
try {
|
|
7676
7717
|
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7677
|
-
const ncKey =
|
|
7718
|
+
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
7678
7719
|
await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
|
|
7679
7720
|
} catch (err) {
|
|
7680
7721
|
this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
|