@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 `stableId` into its HA `device_id`. */
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 `stableId`, never from the numeric id: numeric ids are
29
- * reallocated by a re-sync, and an operator who re-adopts a camera would
30
- * get a second HA device with every automation pointing at the orphan.
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 deviceKeyFor(stableId) {
33
- return `${DEVICE_KEY_PREFIX}${stableId}`;
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_<stableId>_camera`.
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 has carried `camstack_<stableId>_camera` since the integration
150
- * shipped. Announcing a component for it would claim the same
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.stableId}_${derivedEntityId(input.capName)}`,
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.stableId);
1177
+ const deviceKey = deviceKeyFor(device.id);
1140
1178
  return {
1141
1179
  platform: spec.platform,
1142
- unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
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.stableId)],
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.stableId),
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.stableId),
2920
- stableId: device.stableId,
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`): `device_id` is `camstack-<stableId>` and `unique_id` is
5131
- * `<stableId>_<entity>`. The stable ids here are prefixed `synthetic-` so they
5132
- * can never collide with a real device's, whatever a provider mints.
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) => deviceKeyFor(id) === deviceKey);
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(stableId, specs) {
5172
- const deviceKey = deviceKeyFor(stableId);
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: `${stableId}_${spec.entity}`,
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(stableId, name, specs) {
5234
+ function plan(syntheticId, name, specs) {
5193
5235
  return {
5194
- deviceKey: deviceKeyFor(stableId),
5236
+ deviceKey: syntheticDeviceKeyFor(syntheticId),
5195
5237
  deviceId: null,
5196
5238
  dev: {
5197
- ids: [deviceKeyFor(stableId)],
5239
+ ids: [syntheticDeviceKeyFor(syntheticId)],
5198
5240
  name,
5199
5241
  mf: "CamStack",
5200
5242
  mdl: "Synthetic"
5201
5243
  },
5202
- cmps: buildComponents(stableId, specs)
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 = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
5574
- const srv = deviceKeyFor(SERVER_STABLE_ID);
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 = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
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 === deviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
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 = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
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 `stableId` into its HA `device_id`. */
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 `stableId`, never from the numeric id: numeric ids are
24
- * reallocated by a re-sync, and an operator who re-adopts a camera would
25
- * get a second HA device with every automation pointing at the orphan.
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 deviceKeyFor(stableId) {
28
- return `${DEVICE_KEY_PREFIX}${stableId}`;
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_<stableId>_camera`.
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 has carried `camstack_<stableId>_camera` since the integration
145
- * shipped. Announcing a component for it would claim the same
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.stableId}_${derivedEntityId(input.capName)}`,
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.stableId);
1172
+ const deviceKey = deviceKeyFor(device.id);
1135
1173
  return {
1136
1174
  platform: spec.platform,
1137
- unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
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.stableId)],
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.stableId),
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.stableId),
2915
- stableId: device.stableId,
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`): `device_id` is `camstack-<stableId>` and `unique_id` is
5126
- * `<stableId>_<entity>`. The stable ids here are prefixed `synthetic-` so they
5127
- * can never collide with a real device's, whatever a provider mints.
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) => deviceKeyFor(id) === deviceKey);
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(stableId, specs) {
5167
- const deviceKey = deviceKeyFor(stableId);
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: `${stableId}_${spec.entity}`,
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(stableId, name, specs) {
5229
+ function plan(syntheticId, name, specs) {
5188
5230
  return {
5189
- deviceKey: deviceKeyFor(stableId),
5231
+ deviceKey: syntheticDeviceKeyFor(syntheticId),
5190
5232
  deviceId: null,
5191
5233
  dev: {
5192
- ids: [deviceKeyFor(stableId)],
5234
+ ids: [syntheticDeviceKeyFor(syntheticId)],
5193
5235
  name,
5194
5236
  mf: "CamStack",
5195
5237
  mdl: "Synthetic"
5196
5238
  },
5197
- cmps: buildComponents(stableId, specs)
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 = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
5569
- const srv = deviceKeyFor(SERVER_STABLE_ID);
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 = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
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 === deviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
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 = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
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) } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.65",
3
+ "version": "1.2.66",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",