@camstack/addon-provider-homeassistant 1.2.20 → 1.2.22

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.
@@ -1,4 +1,4 @@
1
- import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-BSQpoIVg.mjs";
1
+ import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-CfOPccIv.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  //#region src/ha-export/topics.ts
4
4
  /**
@@ -333,8 +333,25 @@ function cameraSpecs(device) {
333
333
  label: "Audio level",
334
334
  unit: "dBFS",
335
335
  icon: "mdi:volume-high",
336
- enabledByDefault: false
336
+ enabledByDefault: true
337
337
  },
338
+ (
339
+ /**
340
+ * BOTH levels, because they answer different questions and neither
341
+ * derives from the other in a way an operator should have to do in a
342
+ * template. dBFS is logarithmic and negative-going — the one to threshold
343
+ * on ("louder than -30") — while RMS is the linear amplitude, which is
344
+ * what a graph of relative loudness wants. The analyzer computes both in
345
+ * the same window; publishing one and making the other a `log10` template
346
+ * in Home Assistant is work we already did.
347
+ */
348
+ {
349
+ entity: "audio_level_rms",
350
+ platform: "sensor",
351
+ label: "Audio level (RMS)",
352
+ icon: "mdi:waveform",
353
+ enabledByDefault: true
354
+ }),
338
355
  {
339
356
  entity: "last_detection",
340
357
  platform: "sensor",
@@ -1569,6 +1586,10 @@ function projectAudioWindow(deviceKey, input) {
1569
1586
  topic: stateTopic(deviceKey, "audio_volume"),
1570
1587
  value: String(Math.round(input.dbfs * 10) / 10)
1571
1588
  });
1589
+ if (input.rms !== void 0 && Number.isFinite(input.rms)) values.push({
1590
+ topic: stateTopic(deviceKey, "audio_level_rms"),
1591
+ value: String(Math.round(input.rms * 1e4) / 1e4)
1592
+ });
1572
1593
  const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
1573
1594
  if (best !== null) values.push({
1574
1595
  topic: stateTopic(deviceKey, "audio_last_sound"),
@@ -1576,6 +1597,293 @@ function projectAudioWindow(deviceKey, input) {
1576
1597
  });
1577
1598
  return values;
1578
1599
  }
1600
+ //#endregion
1601
+ //#region src/ha-export/synthetic-devices.ts
1602
+ /**
1603
+ * Synthetic devices — what belongs to the SERVER, exported as devices that
1604
+ * exist inside Home Assistant and nowhere else.
1605
+ *
1606
+ * They are **not camstack devices**, deliberately (operator, 2026-08-04 and
1607
+ * again 2026-08-09). They carry rule switches, cluster monitoring and addon
1608
+ * health, none of which is a device in camstack's model; putting them in the
1609
+ * device list would fill it with things that are not devices. So they are built
1610
+ * HERE, by the exporter, and they have no `deviceId`.
1611
+ *
1612
+ * A LIST, not two special cases. The spec is explicit that the concept is
1613
+ * repeatable and more will be added when something needs a home — a one-off
1614
+ * would have to be refactored the first time a third is wanted. Adding one is
1615
+ * adding an entry to {@link SYNTHETIC_DEVICES} plus its projection.
1616
+ *
1617
+ * **Their values are RECONCILE-ONLY, on purpose.** Cluster CPU, memory, addon
1618
+ * counts and rule state are polled statistics, not events: there is no
1619
+ * `nodes.cpu-changed` to subscribe to, and inventing one to make this look
1620
+ * event-driven would be a second authority for numbers the topology already
1621
+ * owns. They refresh on the reconcile cadence (`reconcileIntervalSec`, 300s by
1622
+ * default), which is also what makes them self-healing after a Home Assistant
1623
+ * restart.
1624
+ *
1625
+ * The identity rules are the same wire format as every other device
1626
+ * (`../CLAUDE.md`): `device_id` is `camstack-<stableId>` and `unique_id` is
1627
+ * `<stableId>_<entity>`. The stable ids here are prefixed `synthetic-` so they
1628
+ * can never collide with a real device's, whatever a provider mints.
1629
+ */
1630
+ /** The stable id of the notification-centre device. A WIRE FORMAT. */
1631
+ var NOTIFICATION_CENTER_STABLE_ID = "synthetic-notification-center";
1632
+ /** The stable id of the server device. A WIRE FORMAT. */
1633
+ var SERVER_STABLE_ID = "synthetic-server";
1634
+ var SYNTHETIC_STABLE_IDS = [NOTIFICATION_CENTER_STABLE_ID, SERVER_STABLE_ID];
1635
+ /** Is this device key one of ours? Used by the command route. */
1636
+ function isSyntheticDeviceKey(deviceKey) {
1637
+ return SYNTHETIC_STABLE_IDS.some((id) => deviceKeyFor(id) === deviceKey);
1638
+ }
1639
+ /**
1640
+ * Everything a synthetic device exports arrives ENABLED.
1641
+ *
1642
+ * The pressure valve exists for the camera fan-out — three zones is ~73
1643
+ * entities and the fleet is ~880. There is no fan-out here: the largest of
1644
+ * these is one switch per notification rule, and a rule the operator wrote is
1645
+ * a rule they want to reach. Shipping them disabled would repeat the bug that
1646
+ * exported a thermometer's temperature switched off.
1647
+ */
1648
+ function buildComponents(stableId, specs) {
1649
+ const deviceKey = deviceKeyFor(stableId);
1650
+ const cmps = {};
1651
+ for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = {
1652
+ platform: spec.platform,
1653
+ unique_id: `${stableId}_${spec.entity}`,
1654
+ name: spec.label,
1655
+ ...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
1656
+ ...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
1657
+ ...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
1658
+ ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
1659
+ ...spec.icon !== void 0 ? { icon: spec.icon } : {},
1660
+ ...spec.options !== void 0 ? { options: spec.options } : {},
1661
+ ...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
1662
+ ...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
1663
+ payload_on: "true",
1664
+ payload_off: "false"
1665
+ } : {}
1666
+ };
1667
+ return cmps;
1668
+ }
1669
+ function plan(stableId, name, specs) {
1670
+ return {
1671
+ deviceKey: deviceKeyFor(stableId),
1672
+ deviceId: null,
1673
+ dev: {
1674
+ ids: [deviceKeyFor(stableId)],
1675
+ name,
1676
+ mf: "CamStack",
1677
+ mdl: "Synthetic"
1678
+ },
1679
+ cmps: buildComponents(stableId, specs)
1680
+ };
1681
+ }
1682
+ /**
1683
+ * The rule switch entity for a rule id.
1684
+ *
1685
+ * Slugged, because a rule id is free text and an entity segment is not. The
1686
+ * `unique_id` is derived from the rule ID and never its NAME, so a renamed rule
1687
+ * keeps every automation pointed at it — the friendly name changes in Home
1688
+ * Assistant and nothing else does.
1689
+ */
1690
+ function ruleEntity(ruleId) {
1691
+ return `rule_${toSlug(ruleId)}`;
1692
+ }
1693
+ /** `rule_<slug>` → the rule id it came from, or null. */
1694
+ function ruleIdFromEntity(entity, rules) {
1695
+ return rules.find((rule) => ruleEntity(rule.id) === entity)?.id ?? null;
1696
+ }
1697
+ function notificationCenterSpecs(input) {
1698
+ return [
1699
+ {
1700
+ entity: "snoozed",
1701
+ platform: "binary_sensor",
1702
+ label: "Notifications snoozed",
1703
+ icon: "mdi:bell-sleep"
1704
+ },
1705
+ {
1706
+ entity: "rules_enabled",
1707
+ platform: "sensor",
1708
+ label: "Rules enabled",
1709
+ icon: "mdi:counter",
1710
+ entityCategory: "diagnostic"
1711
+ },
1712
+ ...input.rules.map((rule) => ({
1713
+ entity: ruleEntity(rule.id),
1714
+ platform: "switch",
1715
+ label: rule.name,
1716
+ writable: true,
1717
+ icon: "mdi:bell-cog"
1718
+ }))
1719
+ ];
1720
+ }
1721
+ function serverSpecs(input) {
1722
+ return [
1723
+ {
1724
+ entity: "cluster_healthy",
1725
+ platform: "binary_sensor",
1726
+ label: "Cluster healthy",
1727
+ icon: "mdi:server-network"
1728
+ },
1729
+ {
1730
+ entity: "nodes_online",
1731
+ platform: "sensor",
1732
+ label: "Nodes online",
1733
+ icon: "mdi:server"
1734
+ },
1735
+ {
1736
+ entity: "nodes_total",
1737
+ platform: "sensor",
1738
+ label: "Nodes total",
1739
+ icon: "mdi:server"
1740
+ },
1741
+ {
1742
+ entity: "addons_healthy",
1743
+ platform: "binary_sensor",
1744
+ label: "Addons healthy",
1745
+ icon: "mdi:puzzle-check"
1746
+ },
1747
+ {
1748
+ entity: "addons_running",
1749
+ platform: "sensor",
1750
+ label: "Addons running",
1751
+ icon: "mdi:puzzle"
1752
+ },
1753
+ {
1754
+ entity: "addons_failed",
1755
+ platform: "sensor",
1756
+ label: "Addons failed",
1757
+ icon: "mdi:puzzle-remove"
1758
+ },
1759
+ {
1760
+ entity: "server_version",
1761
+ platform: "sensor",
1762
+ label: "Server version",
1763
+ icon: "mdi:tag",
1764
+ entityCategory: "diagnostic"
1765
+ },
1766
+ {
1767
+ entity: "update_available",
1768
+ platform: "binary_sensor",
1769
+ label: "Update available",
1770
+ deviceClass: "update",
1771
+ entityCategory: "diagnostic"
1772
+ },
1773
+ ...input.nodes.flatMap((node) => {
1774
+ const slug = toSlug(node.id);
1775
+ return [
1776
+ {
1777
+ entity: `${slug}_online`,
1778
+ platform: "binary_sensor",
1779
+ label: `${node.name} online`,
1780
+ deviceClass: "connectivity"
1781
+ },
1782
+ {
1783
+ entity: `${slug}_cpu`,
1784
+ platform: "sensor",
1785
+ label: `${node.name} CPU`,
1786
+ unit: "%",
1787
+ icon: "mdi:cpu-64-bit"
1788
+ },
1789
+ {
1790
+ entity: `${slug}_memory`,
1791
+ platform: "sensor",
1792
+ label: `${node.name} memory`,
1793
+ unit: "%",
1794
+ icon: "mdi:memory"
1795
+ },
1796
+ {
1797
+ entity: `${slug}_uptime`,
1798
+ platform: "sensor",
1799
+ label: `${node.name} uptime`,
1800
+ deviceClass: "timestamp"
1801
+ }
1802
+ ];
1803
+ })
1804
+ ];
1805
+ }
1806
+ /** Every synthetic device, in declaration order. */
1807
+ function syntheticPlans(input) {
1808
+ return [plan(NOTIFICATION_CENTER_STABLE_ID, "Notification Center", notificationCenterSpecs(input)), plan(SERVER_STABLE_ID, "CamStack Server", serverSpecs(input))];
1809
+ }
1810
+ /**
1811
+ * Every synthetic value, for the reconcile to push.
1812
+ *
1813
+ * Uptime is published as an ISO timestamp of the BOOT INSTANT, not as a
1814
+ * seconds counter: HA's `timestamp` device class renders "3 days ago" and keeps
1815
+ * doing so between reconciles, whereas a seconds number would sit frozen at
1816
+ * whatever it was 300 seconds ago and read as a stalled machine.
1817
+ */
1818
+ function projectSynthetic(input, nowMs) {
1819
+ const nc = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
1820
+ const srv = deviceKeyFor(SERVER_STABLE_ID);
1821
+ const online = input.nodes.filter((node) => node.online);
1822
+ const values = [
1823
+ {
1824
+ topic: stateTopic(nc, "snoozed"),
1825
+ value: String(input.snoozed)
1826
+ },
1827
+ {
1828
+ topic: stateTopic(nc, "rules_enabled"),
1829
+ value: String(input.rules.filter((rule) => rule.enabled).length)
1830
+ },
1831
+ ...input.rules.map((rule) => ({
1832
+ topic: stateTopic(nc, ruleEntity(rule.id)),
1833
+ value: String(rule.enabled)
1834
+ })),
1835
+ {
1836
+ topic: stateTopic(srv, "cluster_healthy"),
1837
+ value: String(online.length === input.nodes.length)
1838
+ },
1839
+ {
1840
+ topic: stateTopic(srv, "nodes_online"),
1841
+ value: String(online.length)
1842
+ },
1843
+ {
1844
+ topic: stateTopic(srv, "nodes_total"),
1845
+ value: String(input.nodes.length)
1846
+ },
1847
+ {
1848
+ topic: stateTopic(srv, "addons_healthy"),
1849
+ value: String(input.addonsFailed === 0)
1850
+ },
1851
+ {
1852
+ topic: stateTopic(srv, "addons_running"),
1853
+ value: String(input.addonsRunning)
1854
+ },
1855
+ {
1856
+ topic: stateTopic(srv, "addons_failed"),
1857
+ value: String(input.addonsFailed)
1858
+ },
1859
+ {
1860
+ topic: stateTopic(srv, "server_version"),
1861
+ value: input.serverVersion
1862
+ },
1863
+ {
1864
+ topic: stateTopic(srv, "update_available"),
1865
+ value: String(input.updateAvailable)
1866
+ }
1867
+ ];
1868
+ for (const node of input.nodes) {
1869
+ const slug = toSlug(node.id);
1870
+ values.push({
1871
+ topic: stateTopic(srv, `${slug}_online`),
1872
+ value: String(node.online)
1873
+ }, {
1874
+ topic: stateTopic(srv, `${slug}_cpu`),
1875
+ value: String(Math.round(node.cpuPercent))
1876
+ }, {
1877
+ topic: stateTopic(srv, `${slug}_memory`),
1878
+ value: String(Math.round(node.memoryPercent))
1879
+ });
1880
+ if (node.online && node.uptime > 0) values.push({
1881
+ topic: stateTopic(srv, `${slug}_uptime`),
1882
+ value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
1883
+ });
1884
+ }
1885
+ return values;
1886
+ }
1579
1887
  /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1580
1888
  var HA_BROKER_KIND = "home-assistant";
1581
1889
  /**
@@ -1669,6 +1977,12 @@ var HaExportAddon = class extends BaseAddon {
1669
1977
  doorbellReleaseTimers = /* @__PURE__ */ new Map();
1670
1978
  unclassified = [];
1671
1979
  /**
1980
+ * The rules as of the last reconcile, so a command arriving on
1981
+ * `rule_<slug>` can be turned back into the rule id it came from. Refreshed
1982
+ * whenever the synthetic devices are, never written to.
1983
+ */
1984
+ syntheticRules = [];
1985
+ /**
1672
1986
  * A link that returned repairs NOW, not at the next periodic pass.
1673
1987
  *
1674
1988
  * `PushClient` drops its dedup cache the moment Home Assistant answers
@@ -1936,19 +2250,45 @@ var HaExportAddon = class extends BaseAddon {
1936
2250
  if (deviceKey === void 0) return;
1937
2251
  const frame = readRecordField(data, "frame");
1938
2252
  if (frame === null || typeof frame !== "object" || Array.isArray(frame)) return;
1939
- const dbfs = readNumber(readRecord(frame, "level"), "dbfs");
2253
+ const level = readRecord(frame, "level");
2254
+ const dbfs = readNumber(level, "dbfs");
2255
+ const rms = readNumber(level, "rms");
1940
2256
  const raw = readRecordField(frame, "detections");
2257
+ /**
2258
+ * An `AudioDetection` carries `macroClass` + `score` and its real sound
2259
+ * class in `labels: ScoredLabel[]` — the enrichment chain. It has NO
2260
+ * `className` and no `confidence`; reading those invented names mapped
2261
+ * every window to `[]`, so `audio_last_sound` sat at `unknown` for ever
2262
+ * while the analyzer was classifying happily (found 2026-08-09, symptom:
2263
+ * "le audio labels sembrano non arrivare").
2264
+ *
2265
+ * The best LABEL wins over the macro: `audio` as a value tells an operator
2266
+ * nothing, `dog` or `crying` is the whole point. Falls back to the macro
2267
+ * class only when a detection carries no label at all.
2268
+ */
1941
2269
  const detections = Array.isArray(raw) ? raw.flatMap((entry) => {
1942
- const className = readString(entry, "className");
1943
- const confidence = readNumber(entry, "confidence");
1944
- return className === null || confidence === null ? [] : [{
1945
- className,
1946
- confidence
2270
+ const labels = readRecordField(entry, "labels");
2271
+ const best = Array.isArray(labels) ? labels.reduce((top, l) => {
2272
+ const label = readString(l, "label");
2273
+ const score = readNumber(l, "score") ?? 0;
2274
+ if (label === null) return top;
2275
+ return top === null || score > top.confidence ? {
2276
+ className: label,
2277
+ confidence: score
2278
+ } : top;
2279
+ }, null) : null;
2280
+ if (best !== null) return [best];
2281
+ const macro = readString(entry, "macroClass");
2282
+ const score = readNumber(entry, "score");
2283
+ return macro === null || score === null ? [] : [{
2284
+ className: macro,
2285
+ confidence: score
1947
2286
  }];
1948
2287
  }) : [];
1949
2288
  this.push(deviceId, projectAudioWindow(deviceKey, {
1950
2289
  detections,
1951
- ...dbfs !== null ? { dbfs } : {}
2290
+ ...dbfs !== null ? { dbfs } : {},
2291
+ ...rms !== null ? { rms } : {}
1952
2292
  }));
1953
2293
  }
1954
2294
  onZoneOccupancy(data) {
@@ -2106,12 +2446,35 @@ var HaExportAddon = class extends BaseAddon {
2106
2446
  }
2107
2447
  });
2108
2448
  }
2449
+ /**
2450
+ * The synthetic devices — server-owned surfaces that are not camstack
2451
+ * devices. They are appended to the SAME `exported` map so they travel
2452
+ * the ordinary announce + push path: one code path for identity,
2453
+ * batching and dedup, rather than a parallel one that would drift.
2454
+ * `brokerIds` is every enabled broker, because they belong to the server
2455
+ * and not to a membership the operator picks per camera.
2456
+ */
2457
+ const synthetic = await this.buildSynthetic();
2458
+ if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
2459
+ exported.set(plan.deviceKey, {
2460
+ plan,
2461
+ deviceId: -1,
2462
+ target: {
2463
+ deviceId: -1,
2464
+ type: "synthetic",
2465
+ boundCaps: []
2466
+ },
2467
+ brokerIds: [...enabled]
2468
+ });
2469
+ entityCount += Object.keys(plan.cmps).length;
2470
+ }
2109
2471
  this.exported = exported;
2110
2472
  this.keyByDeviceId = keyByDeviceId;
2111
2473
  this.entityCount = entityCount;
2112
2474
  this.unclassified = unclassifiedCaps([...allCaps]);
2113
2475
  await this.announce(exported);
2114
2476
  await this.pushFullState(exported, snapshots);
2477
+ if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
2115
2478
  this.ctx.logger.info("ha-export: reconciled", { meta: {
2116
2479
  reason,
2117
2480
  brokers: enabled.length,
@@ -2134,6 +2497,69 @@ var HaExportAddon = class extends BaseAddon {
2134
2497
  * that device only and can never take the loop — or the runner — down
2135
2498
  * with it.
2136
2499
  */
2500
+ /**
2501
+ * Gather the synthetic devices' statistics.
2502
+ *
2503
+ * Best-effort per source: one API that does not answer costs its own
2504
+ * entities for this pass, never the whole set — a hub whose
2505
+ * `notificationRules` provider is restarting must still export its cluster
2506
+ * health, which is exactly when an operator wants it.
2507
+ *
2508
+ * Returns `null` only when NOTHING answered, so the devices are not
2509
+ * announced empty on a hub that is still booting.
2510
+ */
2511
+ async buildSynthetic() {
2512
+ const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
2513
+ id: rule.id,
2514
+ name: rule.name,
2515
+ enabled: rule.enabled
2516
+ }))).catch((err) => {
2517
+ this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
2518
+ return null;
2519
+ });
2520
+ const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes.length > 0).catch(() => null);
2521
+ const nodes = await this.ctx.api.nodes.topology.query().then((list) => list.map((node) => ({
2522
+ id: node.id,
2523
+ name: node.name,
2524
+ online: node.isOnline,
2525
+ cpuPercent: node.cpuPercent,
2526
+ memoryPercent: node.memoryPercent,
2527
+ uptime: node.uptime
2528
+ }))).catch((err) => {
2529
+ this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
2530
+ return null;
2531
+ });
2532
+ const addons = await this.ctx.api.addons.list.query().then((list) => {
2533
+ let running = 0;
2534
+ let failed = 0;
2535
+ for (const addon of list) if (addon.health?.phase === "failed") failed += 1;
2536
+ else running += 1;
2537
+ return {
2538
+ running,
2539
+ failed
2540
+ };
2541
+ }).catch(() => null);
2542
+ const server = await this.ctx.api.serverManagement.getServerPackageStatus.query().catch(() => null);
2543
+ if (rules === null && nodes === null && addons === null && server === null) return null;
2544
+ this.syntheticRules = rules ?? this.syntheticRules;
2545
+ return {
2546
+ rules: rules ?? this.syntheticRules,
2547
+ snoozed: snoozes ?? false,
2548
+ nodes: nodes ?? [],
2549
+ addonsRunning: addons?.running ?? 0,
2550
+ addonsFailed: addons?.failed ?? 0,
2551
+ serverVersion: server?.runningVersion ?? server?.activeVersion ?? "unknown",
2552
+ updateAvailable: server?.updateAvailable ?? false
2553
+ };
2554
+ }
2555
+ /** Push the synthetic values to every enabled broker. */
2556
+ async pushSynthetic(values, brokerIds) {
2557
+ for (const brokerId of brokerIds) {
2558
+ const link = this.links.get(brokerId);
2559
+ if (link === void 0) continue;
2560
+ for (const value of values) link.client.publishState(value.topic, value.value);
2561
+ }
2562
+ }
2137
2563
  async announce(exported) {
2138
2564
  for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
2139
2565
  const link = this.links.get(brokerId);
@@ -2308,7 +2734,26 @@ var HaExportAddon = class extends BaseAddon {
2308
2734
  id: broker.id,
2309
2735
  name: broker.name
2310
2736
  }));
2311
- const pruned = pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
2737
+ /**
2738
+ * NEVER prune on an empty enumeration.
2739
+ *
2740
+ * `broker.list` is a fan-out union, so it answers `[]` for the whole window
2741
+ * in which `provider-homeassistant` has not finished registering — and it
2742
+ * registers its caps only at the END of an `initialize()` that restores 239
2743
+ * devices. An empty list there means "I do not know yet", never "the
2744
+ * operator deleted their instance", and the two were indistinguishable to
2745
+ * this code: every boot pruned the membership to nothing and PERSISTED it,
2746
+ * so the operator's exported cameras silently switched themselves off. Seen
2747
+ * live on 2026-08-09 — two cameras exposed, `devices=4`, addon redeployed,
2748
+ * `devices=2` and both switches back to `false` with no line anywhere.
2749
+ *
2750
+ * This is D49 exactly: a destructive step gated on a fallible read must not
2751
+ * fire on the reading that destroys work. A broker that really was deleted
2752
+ * is pruned on the next pass that returns a NON-empty list without it,
2753
+ * which costs one interval and cannot cost the operator their config.
2754
+ */
2755
+ const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
2756
+ if (haBrokers.length === 0 && Object.keys(this.config.membership).length > 0) this.ctx.logger.warn("ha-export: no Home Assistant broker answered — keeping the export membership rather than pruning it", { meta: { brokers: Object.keys(this.config.membership) } });
2312
2757
  if (pruned !== this.config.membership || !sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({
2313
2758
  knownBrokers: known,
2314
2759
  membership: pruned
@@ -2431,6 +2876,49 @@ var HaExportAddon = class extends BaseAddon {
2431
2876
  reply.send({ error: "device not exported" });
2432
2877
  return;
2433
2878
  }
2879
+ /**
2880
+ * A synthetic device has no camstack device behind it, so
2881
+ * `resolveCommand` — which routes by device type and bound caps — cannot
2882
+ * speak for it. Its commands go straight to the authority that already
2883
+ * owns the function, exactly as a camera switch does: the export writes
2884
+ * `setRuleEnabled`, never a store of its own. A second knob that can
2885
+ * disagree with the admin UI is worse than no knob (D62).
2886
+ */
2887
+ if (isSyntheticDeviceKey(parsed.deviceKey)) {
2888
+ const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
2889
+ if (ruleId === null) {
2890
+ this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
2891
+ topic,
2892
+ entity: parsed.entity
2893
+ } });
2894
+ reply.status(422);
2895
+ reply.send({ error: "unroutable command" });
2896
+ return;
2897
+ }
2898
+ const enabled = value.toLowerCase() === "true";
2899
+ try {
2900
+ await this.ctx.api.notificationRules.setRuleEnabled.mutate({
2901
+ ruleId,
2902
+ enabled
2903
+ });
2904
+ this.ctx.logger.info("ha-export: notification rule toggled from Home Assistant", { meta: {
2905
+ ruleId,
2906
+ enabled
2907
+ } });
2908
+ await this.reconcile("synthetic-command");
2909
+ reply.status(200);
2910
+ reply.send({});
2911
+ } catch (err) {
2912
+ this.ctx.logger.warn("ha-export: could not apply a notification-rule toggle", { meta: {
2913
+ ruleId,
2914
+ enabled,
2915
+ error: errMsg(err)
2916
+ } });
2917
+ reply.status(422);
2918
+ reply.send({ error: "command not applied" });
2919
+ }
2920
+ return;
2921
+ }
2434
2922
  const command = resolveCommand(exported.target, parsed.entity, value);
2435
2923
  if (command === null) {
2436
2924
  this.ctx.logger.warn("ha-export: dropping an unroutable command", {
@@ -2781,13 +3269,34 @@ var HaExportAddon = class extends BaseAddon {
2781
3269
  const idStr = String(deviceId);
2782
3270
  let next = this.config.membership;
2783
3271
  let touched = false;
2784
- for (const brokerId of this.enabledBrokerIds()) {
2785
- const key = `haExport:${deviceId}:${brokerId}`;
2786
- if (!(key in patch)) continue;
2787
- next = setExposed(next, brokerId, idStr, patch[key] === true);
3272
+ /**
3273
+ * Driven by the PATCH KEYS, not by the enabled-broker list.
3274
+ *
3275
+ * The key already carries the broker (`haExport:<deviceId>:<brokerId>`)
3276
+ * the operator could not have been shown the switch otherwise — so reading
3277
+ * the broker from it needs no other state to be warm. Iterating
3278
+ * `enabledBrokerIds()` instead made this depend on a read-through cache,
3279
+ * and when that cache was cold the loop matched nothing, `touched` stayed
3280
+ * false, and the method returned `{success: true}` HAVING DONE NOTHING.
3281
+ * The admin UI showed the switch flip and it silently did not persist;
3282
+ * measured live on 2026-08-09 with two cameras that would not stay
3283
+ * exported. A no-op that reports success is worse than an error.
3284
+ */
3285
+ const prefix = `haExport:${deviceId}:`;
3286
+ for (const [key, value] of Object.entries(patch)) {
3287
+ if (!key.startsWith(prefix)) continue;
3288
+ const brokerId = key.slice(prefix.length);
3289
+ if (brokerId.length === 0) continue;
3290
+ next = setExposed(next, brokerId, idStr, value === true);
2788
3291
  touched = true;
2789
3292
  }
2790
- if (!touched) return { success: true };
3293
+ if (!touched) {
3294
+ this.ctx.logger.warn("ha-export: a device patch carried no export key — nothing applied", {
3295
+ tags: { deviceId },
3296
+ meta: { keys: Object.keys(patch) }
3297
+ });
3298
+ return { success: true };
3299
+ }
2791
3300
  await this.updateGlobalSettings({ membership: next });
2792
3301
  this.ctx.logger.info("ha-export: per-broker export membership changed", {
2793
3302
  tags: { deviceId },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.20",
3
+ "version": "1.2.22",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",