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