ebus-sdk 0.2.2__tar.gz → 0.3.1__tar.gz
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.
- {ebus_sdk-0.2.2/src/ebus_sdk.egg-info → ebus_sdk-0.3.1}/PKG-INFO +24 -2
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/README.md +22 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/pyproject.toml +2 -2
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/setup.py +1 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/src/ebus_sdk/__init__.py +1 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/src/ebus_sdk/homie.py +205 -40
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1/src/ebus_sdk.egg-info}/PKG-INFO +24 -2
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/src/ebus_sdk.egg-info/requires.txt +1 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/tests/test_controller.py +415 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/LICENSE +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/setup.cfg +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/src/ebus_sdk/property.py +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/src/ebus_sdk.egg-info/SOURCES.txt +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/src/ebus_sdk.egg-info/dependency_links.txt +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/src/ebus_sdk.egg-info/top_level.txt +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/tests/test_homie_device.py +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.1}/tests/test_property.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebus-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Python SDK for Homie MQTT Convention (eBus)
|
|
5
5
|
Author: Clark Communications Corporation
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: ebus-mqtt-client>=0.1.
|
|
23
|
+
Requires-Dist: ebus-mqtt-client>=0.1.6
|
|
24
24
|
Requires-Dist: paho-mqtt>=1.5.0
|
|
25
25
|
Provides-Extra: mdns
|
|
26
26
|
Requires-Dist: zeroconf>=0.131.0; extra == "mdns"
|
|
@@ -131,6 +131,28 @@ for root in roots:
|
|
|
131
131
|
print(f'{descendant.device_id}: {controller.get_effective_state(descendant.device_id)}')
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
Three controller discovery modes select what the controller listens for:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# Wildcard (default) — every device on the broker
|
|
138
|
+
Controller(mqtt_cfg=cfg)
|
|
139
|
+
|
|
140
|
+
# Single-device — subscribe to exactly one device, no children, no wildcards
|
|
141
|
+
Controller(mqtt_cfg=cfg, device_id='panel-1')
|
|
142
|
+
|
|
143
|
+
# Tree-rooted — subscribe to a root and auto-subscribe to its descendants
|
|
144
|
+
# as they're announced; subscription changes are gated on the parent's
|
|
145
|
+
# $state init→ready edge per the Homie 5 spec.
|
|
146
|
+
Controller(mqtt_cfg=cfg, root_device_id='panel-1')
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Tree-rooted mode is the right pick for consumers that want exactly one
|
|
150
|
+
device's tree on a multi-publisher broker — wildcard would re-introduce
|
|
151
|
+
multi-panel scope creep at the application layer, and single-device would
|
|
152
|
+
see the root and none of its children. As the publisher mutates the tree
|
|
153
|
+
(`Device(parent=...)` to add, `child.delete()` to remove), descendants are
|
|
154
|
+
subscribed or dropped on the parent's next init→ready transition.
|
|
155
|
+
|
|
134
156
|
## Module Structure
|
|
135
157
|
|
|
136
158
|
```
|
|
@@ -101,6 +101,28 @@ for root in roots:
|
|
|
101
101
|
print(f'{descendant.device_id}: {controller.get_effective_state(descendant.device_id)}')
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
Three controller discovery modes select what the controller listens for:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Wildcard (default) — every device on the broker
|
|
108
|
+
Controller(mqtt_cfg=cfg)
|
|
109
|
+
|
|
110
|
+
# Single-device — subscribe to exactly one device, no children, no wildcards
|
|
111
|
+
Controller(mqtt_cfg=cfg, device_id='panel-1')
|
|
112
|
+
|
|
113
|
+
# Tree-rooted — subscribe to a root and auto-subscribe to its descendants
|
|
114
|
+
# as they're announced; subscription changes are gated on the parent's
|
|
115
|
+
# $state init→ready edge per the Homie 5 spec.
|
|
116
|
+
Controller(mqtt_cfg=cfg, root_device_id='panel-1')
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Tree-rooted mode is the right pick for consumers that want exactly one
|
|
120
|
+
device's tree on a multi-publisher broker — wildcard would re-introduce
|
|
121
|
+
multi-panel scope creep at the application layer, and single-device would
|
|
122
|
+
see the root and none of its children. As the publisher mutates the tree
|
|
123
|
+
(`Device(parent=...)` to add, `child.delete()` to remove), descendants are
|
|
124
|
+
subscribed or dropped on the parent's next init→ready transition.
|
|
125
|
+
|
|
104
126
|
## Module Structure
|
|
105
127
|
|
|
106
128
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ebus-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Python SDK for Homie MQTT Convention (eBus)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,7 +25,7 @@ classifiers = [
|
|
|
25
25
|
"Topic :: Internet :: WWW/HTTP",
|
|
26
26
|
]
|
|
27
27
|
dependencies = [
|
|
28
|
-
"ebus-mqtt-client>=0.1.
|
|
28
|
+
"ebus-mqtt-client>=0.1.6",
|
|
29
29
|
"paho-mqtt>=1.5.0",
|
|
30
30
|
]
|
|
31
31
|
|
|
@@ -1704,24 +1704,51 @@ class Controller:
|
|
|
1704
1704
|
homie_domain: str = EBUS_HOMIE_DOMAIN,
|
|
1705
1705
|
auto_start: bool = False,
|
|
1706
1706
|
device_id: Optional[str] = None,
|
|
1707
|
+
root_device_id: Optional[str] = None,
|
|
1707
1708
|
qos: int = EBUS_HOMIE_MQTT_QOS,
|
|
1708
1709
|
):
|
|
1709
1710
|
"""
|
|
1710
1711
|
Initialize a Homie Controller
|
|
1711
1712
|
|
|
1713
|
+
Three discovery modes are mutually exclusive:
|
|
1714
|
+
- Wildcard (default): device_id=None, root_device_id=None — sees every
|
|
1715
|
+
device on the broker by subscribing to {domain}/5/+/$state.
|
|
1716
|
+
- Single-device: device_id=<id> — subscribes to exactly that device's
|
|
1717
|
+
four topic patterns; no children, no wildcard in the device-id slot.
|
|
1718
|
+
- Tree-rooted: root_device_id=<id> — starts at the named root, then
|
|
1719
|
+
walks $description.children and subscribes to each descendant as it
|
|
1720
|
+
announces. Subscription changes are gated on the parent's init→ready
|
|
1721
|
+
state edge (per Homie 5: $state=ready is the trust signal).
|
|
1722
|
+
|
|
1712
1723
|
Args:
|
|
1713
1724
|
mqtt_cfg: MQTT broker configuration (same format as Device class)
|
|
1714
1725
|
homie_domain: Homie domain to monitor (default: 'ebus')
|
|
1715
1726
|
auto_start: If True, automatically start discovery on init
|
|
1716
1727
|
device_id: If set, subscribe only to this specific device (no wildcards)
|
|
1728
|
+
root_device_id: If set, subscribe to this root and auto-subscribe to
|
|
1729
|
+
its descendants as the tree is announced (SDK-o1h)
|
|
1717
1730
|
qos: MQTT QoS level for all subscribe/publish operations (default: EBUS_HOMIE_MQTT_QOS)
|
|
1718
1731
|
"""
|
|
1732
|
+
if device_id is not None and root_device_id is not None:
|
|
1733
|
+
raise ValueError(
|
|
1734
|
+
"device_id and root_device_id are mutually exclusive; "
|
|
1735
|
+
"pick single-device mode (device_id) or tree-rooted mode (root_device_id)"
|
|
1736
|
+
)
|
|
1737
|
+
|
|
1719
1738
|
self.homie_domain = homie_domain
|
|
1720
1739
|
self.device_id = device_id
|
|
1740
|
+
self.root_device_id = root_device_id
|
|
1721
1741
|
self._qos = qos
|
|
1722
1742
|
self._mqtt_cfg = mqtt_cfg
|
|
1723
1743
|
self.mqttc = None
|
|
1724
1744
|
self.devices = {} # {device_id: DiscoveredDevice}
|
|
1745
|
+
# Tree-rooted mode: {parent_device_id: set_of_subscribed_child_ids}.
|
|
1746
|
+
# Authoritative record of what we've subscribed for under each parent,
|
|
1747
|
+
# independent of any child's own description (which may not have
|
|
1748
|
+
# arrived yet). Reconcile diffs against this rather than walking
|
|
1749
|
+
# parent_id linkages so a pre-created-but-not-yet-described child
|
|
1750
|
+
# doesn't look "missing" and get re-subscribed every reconcile.
|
|
1751
|
+
self._subscribed_children: dict = {}
|
|
1725
1752
|
|
|
1726
1753
|
# Callbacks
|
|
1727
1754
|
self._on_device_discovered = None
|
|
@@ -1736,6 +1763,11 @@ class Controller:
|
|
|
1736
1763
|
if auto_start:
|
|
1737
1764
|
self.start_discovery()
|
|
1738
1765
|
|
|
1766
|
+
@property
|
|
1767
|
+
def is_tree_rooted(self) -> bool:
|
|
1768
|
+
"""True if this controller was created in tree-rooted mode (SDK-o1h)."""
|
|
1769
|
+
return self.root_device_id is not None
|
|
1770
|
+
|
|
1739
1771
|
def _connect_broker(self) -> None:
|
|
1740
1772
|
"""Connect to MQTT broker"""
|
|
1741
1773
|
if self.mqttc:
|
|
@@ -1759,20 +1791,37 @@ class Controller:
|
|
|
1759
1791
|
return self._qos
|
|
1760
1792
|
|
|
1761
1793
|
def _on_connect(self) -> None:
|
|
1762
|
-
"""Called when controller connects to MQTT broker
|
|
1794
|
+
"""Called when controller connects to MQTT broker.
|
|
1795
|
+
|
|
1796
|
+
MqttClient re-subscribes its own sub_callbacks dict on reconnect, so
|
|
1797
|
+
topic-level recovery is already handled. In tree-rooted mode, the
|
|
1798
|
+
controller's own bookkeeping (devices dict, current children) is reset
|
|
1799
|
+
here so the retained $state/$description that paho delivers on the
|
|
1800
|
+
fresh subscriptions drives a clean re-walk of the tree from the root,
|
|
1801
|
+
identical to first-connect.
|
|
1802
|
+
"""
|
|
1763
1803
|
logger.info("reason=controllerOnConnect")
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1804
|
+
if self.is_tree_rooted:
|
|
1805
|
+
# Cold restart of tree-rooted bookkeeping. paho-mqtt's MqttClient
|
|
1806
|
+
# has already re-subscribed our root's four filters; retained
|
|
1807
|
+
# state/description will arrive momentarily and the state-edge
|
|
1808
|
+
# handler will reconcile descendants from scratch. Wipe the
|
|
1809
|
+
# in-memory device registry so the init→ready edge sees the
|
|
1810
|
+
# transition (the previous state would otherwise short-circuit it).
|
|
1811
|
+
self.devices = {}
|
|
1812
|
+
self._subscribed_children = {}
|
|
1813
|
+
device = DiscoveredDevice(self.root_device_id, self.homie_domain)
|
|
1814
|
+
self.devices[self.root_device_id] = device
|
|
1768
1815
|
|
|
1769
1816
|
def start_discovery(self, homie_domain: Optional[str] = None) -> None:
|
|
1770
1817
|
"""
|
|
1771
1818
|
Start auto-discovery of Homie devices
|
|
1772
1819
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1820
|
+
Behavior depends on the constructor-selected mode:
|
|
1821
|
+
- root_device_id: tree-rooted — subscribe to root, then auto-subscribe
|
|
1822
|
+
to descendants on the root's init→ready edge (SDK-o1h).
|
|
1823
|
+
- device_id: single-device — subscribe to exact topics for that device.
|
|
1824
|
+
- neither: wildcard — subscribe to {domain}/5/+/$state.
|
|
1776
1825
|
|
|
1777
1826
|
Args:
|
|
1778
1827
|
homie_domain: Optional specific domain to monitor (default: uses instance domain)
|
|
@@ -1783,46 +1832,56 @@ class Controller:
|
|
|
1783
1832
|
|
|
1784
1833
|
domain = homie_domain or self.homie_domain
|
|
1785
1834
|
|
|
1786
|
-
if self.
|
|
1835
|
+
if self.is_tree_rooted:
|
|
1836
|
+
logger.info(f"reason=startDiscoveryTreeRooted,rootDeviceID={self.root_device_id}")
|
|
1837
|
+
# Pre-create the root entry; descendants are added as they're
|
|
1838
|
+
# discovered via the parent's $description.children
|
|
1839
|
+
device = DiscoveredDevice(self.root_device_id, domain)
|
|
1840
|
+
self.devices[self.root_device_id] = device
|
|
1841
|
+
self._subscribe_device_topics(self.root_device_id)
|
|
1842
|
+
elif self.device_id:
|
|
1787
1843
|
# Single-device mode: subscribe to exact topics, no wildcard
|
|
1788
1844
|
# in the device-id position
|
|
1789
|
-
base = f"{domain}/{EBUS_HOMIE_VERSION_MAJOR}/{self.device_id}"
|
|
1790
1845
|
logger.info(f"reason=startDiscoverySingleDevice,deviceID={self.device_id}")
|
|
1791
|
-
|
|
1792
1846
|
# Pre-create the DiscoveredDevice entry
|
|
1793
1847
|
device = DiscoveredDevice(self.device_id, domain)
|
|
1794
1848
|
self.devices[self.device_id] = device
|
|
1795
|
-
|
|
1796
|
-
# Subscribe to $state
|
|
1797
|
-
self.mqttc.subscribe(
|
|
1798
|
-
f"{base}/$state",
|
|
1799
|
-
param=self._on_state_message,
|
|
1800
|
-
qos=self._qos,
|
|
1801
|
-
)
|
|
1802
|
-
# Subscribe to $description
|
|
1803
|
-
self.mqttc.subscribe(
|
|
1804
|
-
f"{base}/$description",
|
|
1805
|
-
param=partial(self._on_description_message, self.device_id),
|
|
1806
|
-
qos=self._qos,
|
|
1807
|
-
)
|
|
1808
|
-
# Subscribe to all properties: {base}/{node_id}/{property_id}
|
|
1809
|
-
self.mqttc.subscribe(
|
|
1810
|
-
f"{base}/+/+",
|
|
1811
|
-
param=partial(self._on_property_message, self.device_id),
|
|
1812
|
-
qos=self._qos,
|
|
1813
|
-
)
|
|
1814
|
-
# Subscribe to all property targets
|
|
1815
|
-
self.mqttc.subscribe(
|
|
1816
|
-
f"{base}/+/+/$target",
|
|
1817
|
-
param=partial(self._on_target_message, self.device_id),
|
|
1818
|
-
qos=self._qos,
|
|
1819
|
-
)
|
|
1849
|
+
self._subscribe_device_topics(self.device_id)
|
|
1820
1850
|
else:
|
|
1821
1851
|
# Wildcard discovery mode (original behavior)
|
|
1822
1852
|
discovery_topic = f"{domain}/{EBUS_HOMIE_VERSION_MAJOR}/+/$state"
|
|
1823
1853
|
logger.info(f"reason=startDiscovery,topic={discovery_topic}")
|
|
1824
1854
|
self.mqttc.subscribe(discovery_topic, param=self._on_state_message, qos=self._qos)
|
|
1825
1855
|
|
|
1856
|
+
def _subscribe_device_topics(self, device_id: str) -> None:
|
|
1857
|
+
"""Subscribe to the four exact-device topic patterns for device_id.
|
|
1858
|
+
|
|
1859
|
+
Used by single-device mode, tree-rooted mode (for the root and each
|
|
1860
|
+
discovered descendant). The wildcard $state subscription path uses a
|
|
1861
|
+
different shape and bypasses this.
|
|
1862
|
+
"""
|
|
1863
|
+
base = f"{self.homie_domain}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}"
|
|
1864
|
+
self.mqttc.subscribe(
|
|
1865
|
+
f"{base}/$state",
|
|
1866
|
+
param=self._on_state_message,
|
|
1867
|
+
qos=self._qos,
|
|
1868
|
+
)
|
|
1869
|
+
self.mqttc.subscribe(
|
|
1870
|
+
f"{base}/$description",
|
|
1871
|
+
param=partial(self._on_description_message, device_id),
|
|
1872
|
+
qos=self._qos,
|
|
1873
|
+
)
|
|
1874
|
+
self.mqttc.subscribe(
|
|
1875
|
+
f"{base}/+/+",
|
|
1876
|
+
param=partial(self._on_property_message, device_id),
|
|
1877
|
+
qos=self._qos,
|
|
1878
|
+
)
|
|
1879
|
+
self.mqttc.subscribe(
|
|
1880
|
+
f"{base}/+/+/$target",
|
|
1881
|
+
param=partial(self._on_target_message, device_id),
|
|
1882
|
+
qos=self._qos,
|
|
1883
|
+
)
|
|
1884
|
+
|
|
1826
1885
|
def _on_state_message(self, topic: str, payload: bytes) -> None:
|
|
1827
1886
|
"""
|
|
1828
1887
|
Handle device $state messages
|
|
@@ -1854,11 +1913,12 @@ class Controller:
|
|
|
1854
1913
|
# New or existing device
|
|
1855
1914
|
if device_id not in self.devices:
|
|
1856
1915
|
# New device discovered (wildcard mode only; single-device mode
|
|
1857
|
-
# pre-
|
|
1916
|
+
# and tree-rooted mode pre-create their entries in start_discovery)
|
|
1858
1917
|
logger.info(
|
|
1859
1918
|
f"reason=deviceDiscovered,deviceID={device_id},state={payload_str},knownDevices={list(self.devices.keys())}"
|
|
1860
1919
|
)
|
|
1861
1920
|
device = DiscoveredDevice(device_id, homie_domain)
|
|
1921
|
+
old_state = None
|
|
1862
1922
|
device.update_state(payload_str)
|
|
1863
1923
|
self.devices[device_id] = device
|
|
1864
1924
|
|
|
@@ -1868,10 +1928,13 @@ class Controller:
|
|
|
1868
1928
|
if self._on_device_discovered:
|
|
1869
1929
|
self._on_device_discovered(device)
|
|
1870
1930
|
elif self.devices[device_id].state is None:
|
|
1871
|
-
# Pre-created entry (single-device mode):
|
|
1931
|
+
# Pre-created entry (single-device or tree-rooted mode):
|
|
1932
|
+
# first $state message
|
|
1872
1933
|
device = self.devices[device_id]
|
|
1934
|
+
old_state = None
|
|
1873
1935
|
device.update_state(payload_str)
|
|
1874
|
-
|
|
1936
|
+
mode = "treeRooted" if self.is_tree_rooted else "singleDevice"
|
|
1937
|
+
logger.info(f"reason=deviceDiscovered,deviceID={device_id},state={payload_str},mode={mode}")
|
|
1875
1938
|
if self._on_device_discovered:
|
|
1876
1939
|
self._on_device_discovered(device)
|
|
1877
1940
|
else:
|
|
@@ -1892,8 +1955,22 @@ class Controller:
|
|
|
1892
1955
|
device.update_state(payload_str)
|
|
1893
1956
|
logger.debug(f"reason=deviceStateRefreshed,deviceID={device_id},state={payload_str}")
|
|
1894
1957
|
|
|
1958
|
+
# Tree-rooted mode: any device transitioning INTO ready is the trust
|
|
1959
|
+
# signal to act on its $description.children. Per Homie 5, only the
|
|
1960
|
+
# init→ready edge guarantees a consistent description; mid-flight
|
|
1961
|
+
# description updates while state=init are stashed but not acted on.
|
|
1962
|
+
if self.is_tree_rooted and payload_str == DeviceState.READY.value and old_state != DeviceState.READY.value:
|
|
1963
|
+
self._reconcile_descendants(device_id)
|
|
1964
|
+
|
|
1895
1965
|
def _subscribe_to_device(self, device_id: str) -> None:
|
|
1896
|
-
"""Subscribe to all topics for a discovered device
|
|
1966
|
+
"""Subscribe to all topics for a discovered device (wildcard-mode helper).
|
|
1967
|
+
|
|
1968
|
+
Wildcard discovery hears a device's $state via the {domain}/5/+/$state
|
|
1969
|
+
subscription; once it knows the device exists, it needs three more
|
|
1970
|
+
filters (description, properties, targets) to track everything else.
|
|
1971
|
+
Tree-rooted and single-device modes don't go through here — they call
|
|
1972
|
+
_subscribe_device_topics directly to get all four filters at once.
|
|
1973
|
+
"""
|
|
1897
1974
|
if not self.mqttc:
|
|
1898
1975
|
return
|
|
1899
1976
|
|
|
@@ -1923,6 +2000,82 @@ class Controller:
|
|
|
1923
2000
|
qos=self._qos,
|
|
1924
2001
|
)
|
|
1925
2002
|
|
|
2003
|
+
def _reconcile_descendants(self, device_id: str) -> None:
|
|
2004
|
+
"""Diff a device's announced children against what's subscribed (SDK-o1h).
|
|
2005
|
+
|
|
2006
|
+
Fired on the init→ready edge in tree-rooted mode. Added children get
|
|
2007
|
+
full topic subscriptions (their own retained $state/$description then
|
|
2008
|
+
cascade through this same handler, surfacing grandchildren). Removed
|
|
2009
|
+
children are unsubscribed and dropped from the registry recursively.
|
|
2010
|
+
|
|
2011
|
+
The state-edge gate (in _on_state_message) guarantees we only act when
|
|
2012
|
+
$state=ready confirms the description is current — never on a partial
|
|
2013
|
+
view stashed during $state=init.
|
|
2014
|
+
"""
|
|
2015
|
+
if not self.mqttc:
|
|
2016
|
+
return
|
|
2017
|
+
device = self.devices.get(device_id)
|
|
2018
|
+
if device is None:
|
|
2019
|
+
return
|
|
2020
|
+
|
|
2021
|
+
declared = set(device.children_ids)
|
|
2022
|
+
current = set(self._subscribed_children.get(device_id, set()))
|
|
2023
|
+
|
|
2024
|
+
added = declared - current
|
|
2025
|
+
removed = current - declared
|
|
2026
|
+
|
|
2027
|
+
for child_id in added:
|
|
2028
|
+
logger.info(f"reason=treeRootedAddDescendant,parentID={device_id},childID={child_id}")
|
|
2029
|
+
# Pre-create child entry; its own retained $state/$description
|
|
2030
|
+
# arrive via the new subscriptions and drive update + cascade.
|
|
2031
|
+
if child_id not in self.devices:
|
|
2032
|
+
self.devices[child_id] = DiscoveredDevice(child_id, self.homie_domain)
|
|
2033
|
+
self._subscribed_children.setdefault(device_id, set()).add(child_id)
|
|
2034
|
+
self._subscribe_device_topics(child_id)
|
|
2035
|
+
|
|
2036
|
+
for child_id in removed:
|
|
2037
|
+
logger.info(f"reason=treeRootedRemoveDescendant,parentID={device_id},childID={child_id}")
|
|
2038
|
+
self._unsubscribe_and_drop(child_id)
|
|
2039
|
+
|
|
2040
|
+
def _unsubscribe_and_drop(self, device_id: str) -> None:
|
|
2041
|
+
"""Recursively drop a descendant and all of its own descendants (SDK-o1h).
|
|
2042
|
+
|
|
2043
|
+
Unsubscribes the four topic filters, removes the entry from the
|
|
2044
|
+
registry, and fires on_device_removed (leaves-first so callbacks see a
|
|
2045
|
+
consistent view: when fired for a parent, its children are already
|
|
2046
|
+
gone). No-op if the device isn't tracked.
|
|
2047
|
+
"""
|
|
2048
|
+
if device_id not in self.devices:
|
|
2049
|
+
return
|
|
2050
|
+
# Snapshot before mutating: collect this device's transitive
|
|
2051
|
+
# descendants from our subscription registry (the authoritative record
|
|
2052
|
+
# of what we subscribed for; doesn't depend on the child's own
|
|
2053
|
+
# description having arrived). Recurse leaves-first.
|
|
2054
|
+
children = list(self._subscribed_children.get(device_id, set()))
|
|
2055
|
+
for child_id in children:
|
|
2056
|
+
self._unsubscribe_and_drop(child_id)
|
|
2057
|
+
|
|
2058
|
+
# This device is no longer a parent in our tree
|
|
2059
|
+
self._subscribed_children.pop(device_id, None)
|
|
2060
|
+
# Remove this device from any parent's subscribed-children set
|
|
2061
|
+
for siblings in self._subscribed_children.values():
|
|
2062
|
+
siblings.discard(device_id)
|
|
2063
|
+
|
|
2064
|
+
device = self.devices.pop(device_id, None)
|
|
2065
|
+
if device is None:
|
|
2066
|
+
return
|
|
2067
|
+
|
|
2068
|
+
if self.mqttc:
|
|
2069
|
+
base = f"{self.homie_domain}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}"
|
|
2070
|
+
for suffix in ("$state", "$description", "+/+", "+/+/$target"):
|
|
2071
|
+
self.mqttc.unsubscribe(f"{base}/{suffix}")
|
|
2072
|
+
|
|
2073
|
+
if self._on_device_removed:
|
|
2074
|
+
try:
|
|
2075
|
+
self._on_device_removed(device)
|
|
2076
|
+
except Exception:
|
|
2077
|
+
logger.exception(f"reason=onDeviceRemovedCallbackException,deviceID={device_id}")
|
|
2078
|
+
|
|
1926
2079
|
def _on_description_message(self, device_id: str, topic: str, payload: bytes) -> None:
|
|
1927
2080
|
"""Handle device $description messages"""
|
|
1928
2081
|
if device_id not in self.devices:
|
|
@@ -1936,6 +2089,17 @@ class Controller:
|
|
|
1936
2089
|
if self._on_description_received:
|
|
1937
2090
|
self._on_description_received(device)
|
|
1938
2091
|
|
|
2092
|
+
# SDK-gsn: on initial connect with retained state+description, $state
|
|
2093
|
+
# often arrives before $description (we subscribe to $state first, and
|
|
2094
|
+
# paho delivers in subscription order). The state-edge reconcile in
|
|
2095
|
+
# _on_state_message then sees an empty children list and subscribes to
|
|
2096
|
+
# nothing. Catch the late-arriving description here: when the device
|
|
2097
|
+
# is already ready, run reconcile against the now-current description.
|
|
2098
|
+
# Idempotent — a no-op when children are already subscribed, so safe
|
|
2099
|
+
# in the design-intended order (description-then-state) too.
|
|
2100
|
+
if self.is_tree_rooted and device.state == DeviceState.READY.value:
|
|
2101
|
+
self._reconcile_descendants(device_id)
|
|
2102
|
+
|
|
1939
2103
|
def _on_property_message(self, device_id: str, topic: str, payload: bytes) -> None:
|
|
1940
2104
|
"""
|
|
1941
2105
|
Handle property value messages
|
|
@@ -2160,6 +2324,7 @@ class Controller:
|
|
|
2160
2324
|
self.mqttc = None
|
|
2161
2325
|
# Release DiscoveredDevice objects and their property dicts
|
|
2162
2326
|
self.devices.clear()
|
|
2327
|
+
self._subscribed_children.clear()
|
|
2163
2328
|
# Clear callback references to break reference cycles
|
|
2164
2329
|
self._on_device_discovered = None
|
|
2165
2330
|
self._on_device_state_changed = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebus-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Python SDK for Homie MQTT Convention (eBus)
|
|
5
5
|
Author: Clark Communications Corporation
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: ebus-mqtt-client>=0.1.
|
|
23
|
+
Requires-Dist: ebus-mqtt-client>=0.1.6
|
|
24
24
|
Requires-Dist: paho-mqtt>=1.5.0
|
|
25
25
|
Provides-Extra: mdns
|
|
26
26
|
Requires-Dist: zeroconf>=0.131.0; extra == "mdns"
|
|
@@ -131,6 +131,28 @@ for root in roots:
|
|
|
131
131
|
print(f'{descendant.device_id}: {controller.get_effective_state(descendant.device_id)}')
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
Three controller discovery modes select what the controller listens for:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# Wildcard (default) — every device on the broker
|
|
138
|
+
Controller(mqtt_cfg=cfg)
|
|
139
|
+
|
|
140
|
+
# Single-device — subscribe to exactly one device, no children, no wildcards
|
|
141
|
+
Controller(mqtt_cfg=cfg, device_id='panel-1')
|
|
142
|
+
|
|
143
|
+
# Tree-rooted — subscribe to a root and auto-subscribe to its descendants
|
|
144
|
+
# as they're announced; subscription changes are gated on the parent's
|
|
145
|
+
# $state init→ready edge per the Homie 5 spec.
|
|
146
|
+
Controller(mqtt_cfg=cfg, root_device_id='panel-1')
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Tree-rooted mode is the right pick for consumers that want exactly one
|
|
150
|
+
device's tree on a multi-publisher broker — wildcard would re-introduce
|
|
151
|
+
multi-panel scope creep at the application layer, and single-device would
|
|
152
|
+
see the root and none of its children. As the publisher mutates the tree
|
|
153
|
+
(`Device(parent=...)` to add, `child.delete()` to remove), descendants are
|
|
154
|
+
subscribed or dropped on the parent's next init→ready transition.
|
|
155
|
+
|
|
134
156
|
## Module Structure
|
|
135
157
|
|
|
136
158
|
```
|
|
@@ -354,7 +354,7 @@ class TestControllerEffectiveState:
|
|
|
354
354
|
# ── Controller ───────────────────────────────────────────────────────────
|
|
355
355
|
|
|
356
356
|
|
|
357
|
-
def _make_controller(mock_paho, device_id=None, auto_start=False):
|
|
357
|
+
def _make_controller(mock_paho, device_id=None, auto_start=False, root_device_id=None):
|
|
358
358
|
"""Helper to create a Controller with mocked MQTT."""
|
|
359
359
|
with patch("ebus_sdk.homie.MqttClient.from_config") as mock_from_config:
|
|
360
360
|
mock_client = MagicMock()
|
|
@@ -365,6 +365,7 @@ def _make_controller(mock_paho, device_id=None, auto_start=False):
|
|
|
365
365
|
mqtt_cfg={"host": "localhost", "port": 1883},
|
|
366
366
|
auto_start=auto_start,
|
|
367
367
|
device_id=device_id,
|
|
368
|
+
root_device_id=root_device_id,
|
|
368
369
|
)
|
|
369
370
|
return ctrl, mock_client
|
|
370
371
|
|
|
@@ -802,3 +803,416 @@ class TestControllerQoS:
|
|
|
802
803
|
for c in mock_client.subscribe.call_args_list:
|
|
803
804
|
_, kwargs = c
|
|
804
805
|
assert kwargs["qos"] == 1
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
# ── Controller tree-rooted mode (SDK-o1h) ────────────────────────────────
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _push_state(ctrl, device_id, state):
|
|
812
|
+
"""Push a $state retained message into the controller."""
|
|
813
|
+
ctrl._on_state_message(
|
|
814
|
+
f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/$state",
|
|
815
|
+
state.encode() if isinstance(state, str) else state,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _push_description(ctrl, device_id, description):
|
|
820
|
+
"""Push a $description retained message into the controller."""
|
|
821
|
+
ctrl._on_description_message(
|
|
822
|
+
device_id,
|
|
823
|
+
f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/$description",
|
|
824
|
+
json.dumps(description).encode(),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _filters_for(device_id):
|
|
829
|
+
"""The four exact-device topic filters subscribed for one device."""
|
|
830
|
+
base = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}"
|
|
831
|
+
return {
|
|
832
|
+
f"{base}/$state",
|
|
833
|
+
f"{base}/$description",
|
|
834
|
+
f"{base}/+/+",
|
|
835
|
+
f"{base}/+/+/$target",
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
class TestTreeRootedInit:
|
|
840
|
+
def test_mutually_exclusive_with_device_id(self, mock_paho):
|
|
841
|
+
with pytest.raises(ValueError):
|
|
842
|
+
_make_controller(mock_paho, device_id="panel-1", root_device_id="panel-1")
|
|
843
|
+
|
|
844
|
+
def test_root_device_id_stored(self, mock_paho):
|
|
845
|
+
ctrl, _ = _make_controller(mock_paho, root_device_id="panel-1")
|
|
846
|
+
assert ctrl.root_device_id == "panel-1"
|
|
847
|
+
assert ctrl.is_tree_rooted is True
|
|
848
|
+
assert ctrl.device_id is None
|
|
849
|
+
|
|
850
|
+
def test_wildcard_mode_not_tree_rooted(self, mock_paho):
|
|
851
|
+
ctrl, _ = _make_controller(mock_paho)
|
|
852
|
+
assert ctrl.is_tree_rooted is False
|
|
853
|
+
|
|
854
|
+
def test_single_device_mode_not_tree_rooted(self, mock_paho):
|
|
855
|
+
ctrl, _ = _make_controller(mock_paho, device_id="panel-1")
|
|
856
|
+
assert ctrl.is_tree_rooted is False
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
class TestTreeRootedStartDiscovery:
|
|
860
|
+
def test_subscribes_four_root_filters(self, mock_paho):
|
|
861
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
862
|
+
ctrl.start_discovery()
|
|
863
|
+
|
|
864
|
+
topics = {c[0][0] for c in mock_client.subscribe.call_args_list}
|
|
865
|
+
assert topics == _filters_for("panel-1")
|
|
866
|
+
|
|
867
|
+
def test_pre_creates_root_entry(self, mock_paho):
|
|
868
|
+
ctrl, _ = _make_controller(mock_paho, root_device_id="panel-1")
|
|
869
|
+
ctrl.start_discovery()
|
|
870
|
+
|
|
871
|
+
assert "panel-1" in ctrl.devices
|
|
872
|
+
assert ctrl.devices["panel-1"].state is None
|
|
873
|
+
|
|
874
|
+
def test_no_wildcard_subscription(self, mock_paho):
|
|
875
|
+
"""Tree-rooted mode must not subscribe to the broker-wide +/$state."""
|
|
876
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
877
|
+
ctrl.start_discovery()
|
|
878
|
+
|
|
879
|
+
wildcard = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/+/$state"
|
|
880
|
+
topics = [c[0][0] for c in mock_client.subscribe.call_args_list]
|
|
881
|
+
assert wildcard not in topics
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class TestTreeRootedBootstrap:
|
|
885
|
+
"""Retained-state bootstrap: tree announces all-at-once on connect."""
|
|
886
|
+
|
|
887
|
+
def test_root_only_no_children(self, mock_paho):
|
|
888
|
+
ctrl, _ = _make_controller(mock_paho, root_device_id="panel-1")
|
|
889
|
+
ctrl.start_discovery()
|
|
890
|
+
# Retained $description (no children) + retained $state=ready
|
|
891
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0"})
|
|
892
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
893
|
+
|
|
894
|
+
assert set(ctrl.devices.keys()) == {"panel-1"}
|
|
895
|
+
|
|
896
|
+
def test_root_with_children_subscribes_each(self, mock_paho):
|
|
897
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
898
|
+
ctrl.start_discovery()
|
|
899
|
+
# Pretend retained $description arrives first (matches MQTT typical
|
|
900
|
+
# delivery order on a fresh subscription), then $state=ready.
|
|
901
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1", "evse-1"]})
|
|
902
|
+
mock_client.subscribe.reset_mock()
|
|
903
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
904
|
+
|
|
905
|
+
# init→ready edge → reconcile → subscribe to each child's 4 filters
|
|
906
|
+
topics = {c[0][0] for c in mock_client.subscribe.call_args_list}
|
|
907
|
+
assert _filters_for("bess-1") <= topics
|
|
908
|
+
assert _filters_for("evse-1") <= topics
|
|
909
|
+
assert "bess-1" in ctrl.devices
|
|
910
|
+
assert "evse-1" in ctrl.devices
|
|
911
|
+
|
|
912
|
+
def test_grandchild_cascade(self, mock_paho):
|
|
913
|
+
"""3-level tree bootstraps from the root via cascading state-edges."""
|
|
914
|
+
ctrl, _ = _make_controller(mock_paho, root_device_id="panel-1")
|
|
915
|
+
ctrl.start_discovery()
|
|
916
|
+
# Root: parent of bess-1
|
|
917
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
918
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
919
|
+
# bess-1's retained state/desc arrive after subscription
|
|
920
|
+
_push_description(
|
|
921
|
+
ctrl,
|
|
922
|
+
"bess-1",
|
|
923
|
+
{
|
|
924
|
+
"homie": "5.0",
|
|
925
|
+
"root": "panel-1",
|
|
926
|
+
"parent": "panel-1",
|
|
927
|
+
"children": ["mid-1"],
|
|
928
|
+
},
|
|
929
|
+
)
|
|
930
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
931
|
+
# mid-1's retained state/desc
|
|
932
|
+
_push_description(
|
|
933
|
+
ctrl,
|
|
934
|
+
"mid-1",
|
|
935
|
+
{
|
|
936
|
+
"homie": "5.0",
|
|
937
|
+
"root": "panel-1",
|
|
938
|
+
"parent": "bess-1",
|
|
939
|
+
},
|
|
940
|
+
)
|
|
941
|
+
_push_state(ctrl, "mid-1", "ready")
|
|
942
|
+
|
|
943
|
+
assert set(ctrl.devices.keys()) == {"panel-1", "bess-1", "mid-1"}
|
|
944
|
+
|
|
945
|
+
def test_discovery_callbacks_fire_per_descendant(self, mock_paho):
|
|
946
|
+
ctrl, _ = _make_controller(mock_paho, root_device_id="panel-1")
|
|
947
|
+
discovered = []
|
|
948
|
+
ctrl.set_on_device_discovered_callback(lambda d: discovered.append(d.device_id))
|
|
949
|
+
ctrl.start_discovery()
|
|
950
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
951
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
952
|
+
_push_description(
|
|
953
|
+
ctrl,
|
|
954
|
+
"bess-1",
|
|
955
|
+
{
|
|
956
|
+
"homie": "5.0",
|
|
957
|
+
"root": "panel-1",
|
|
958
|
+
"parent": "panel-1",
|
|
959
|
+
},
|
|
960
|
+
)
|
|
961
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
962
|
+
|
|
963
|
+
assert discovered == ["panel-1", "bess-1"]
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
class TestTreeRootedStateGate:
|
|
967
|
+
"""init→ready edge gates reconcile; mid-init updates are stashed."""
|
|
968
|
+
|
|
969
|
+
def test_init_state_does_not_reconcile(self, mock_paho):
|
|
970
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
971
|
+
ctrl.start_discovery()
|
|
972
|
+
# First message is init — no children should be subscribed
|
|
973
|
+
_push_state(ctrl, "panel-1", "init")
|
|
974
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
975
|
+
mock_client.subscribe.reset_mock()
|
|
976
|
+
# Another init refresh — still no reconcile
|
|
977
|
+
_push_state(ctrl, "panel-1", "init")
|
|
978
|
+
|
|
979
|
+
assert "bess-1" not in ctrl.devices
|
|
980
|
+
assert mock_client.subscribe.call_count == 0
|
|
981
|
+
|
|
982
|
+
def test_init_then_ready_reconciles(self, mock_paho):
|
|
983
|
+
ctrl, _ = _make_controller(mock_paho, root_device_id="panel-1")
|
|
984
|
+
ctrl.start_discovery()
|
|
985
|
+
_push_state(ctrl, "panel-1", "init")
|
|
986
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
987
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
988
|
+
|
|
989
|
+
assert "bess-1" in ctrl.devices
|
|
990
|
+
|
|
991
|
+
def test_ready_to_ready_does_not_reconcile(self, mock_paho):
|
|
992
|
+
"""A retained $state=ready republish (no edge) must not re-walk."""
|
|
993
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
994
|
+
ctrl.start_discovery()
|
|
995
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
996
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
997
|
+
# bess-1's tree is now subscribed; reset to see whether the next
|
|
998
|
+
# ready→ready refresh triggers any subscription churn.
|
|
999
|
+
mock_client.subscribe.reset_mock()
|
|
1000
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1001
|
+
|
|
1002
|
+
assert mock_client.subscribe.call_count == 0
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
class TestTreeRootedDynamicAdd:
|
|
1006
|
+
def test_mid_flight_child_addition(self, mock_paho):
|
|
1007
|
+
"""Parent re-enters init, gets new description with extra child,
|
|
1008
|
+
returns to ready → new descendant is auto-subscribed."""
|
|
1009
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1010
|
+
ctrl.start_discovery()
|
|
1011
|
+
# Initial steady-state with one child
|
|
1012
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1013
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1014
|
+
_push_description(
|
|
1015
|
+
ctrl,
|
|
1016
|
+
"bess-1",
|
|
1017
|
+
{
|
|
1018
|
+
"homie": "5.0",
|
|
1019
|
+
"root": "panel-1",
|
|
1020
|
+
"parent": "panel-1",
|
|
1021
|
+
},
|
|
1022
|
+
)
|
|
1023
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1024
|
+
mock_client.subscribe.reset_mock()
|
|
1025
|
+
|
|
1026
|
+
# Mid-flight: parent goes to init, new description includes evse-1
|
|
1027
|
+
_push_state(ctrl, "panel-1", "init")
|
|
1028
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1", "evse-1"]})
|
|
1029
|
+
# No reconcile yet
|
|
1030
|
+
assert "evse-1" not in ctrl.devices
|
|
1031
|
+
# Parent returns to ready → reconcile fires
|
|
1032
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1033
|
+
|
|
1034
|
+
assert "evse-1" in ctrl.devices
|
|
1035
|
+
topics = {c[0][0] for c in mock_client.subscribe.call_args_list}
|
|
1036
|
+
# evse-1's 4 filters subscribed; bess-1's were not re-subscribed
|
|
1037
|
+
assert _filters_for("evse-1") <= topics
|
|
1038
|
+
assert _filters_for("bess-1").isdisjoint(topics)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
class TestTreeRootedDynamicRemove:
|
|
1042
|
+
def test_child_removal_unsubscribes_and_fires_callback(self, mock_paho):
|
|
1043
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1044
|
+
removed = []
|
|
1045
|
+
ctrl.set_on_device_removed_callback(lambda d: removed.append(d.device_id))
|
|
1046
|
+
ctrl.start_discovery()
|
|
1047
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1", "evse-1"]})
|
|
1048
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1049
|
+
_push_description(ctrl, "bess-1", {"homie": "5.0", "root": "panel-1", "parent": "panel-1"})
|
|
1050
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1051
|
+
_push_description(ctrl, "evse-1", {"homie": "5.0", "root": "panel-1", "parent": "panel-1"})
|
|
1052
|
+
_push_state(ctrl, "evse-1", "ready")
|
|
1053
|
+
|
|
1054
|
+
# Reset only AFTER full steady-state, so we see only the unsub for evse-1
|
|
1055
|
+
mock_client.unsubscribe.reset_mock()
|
|
1056
|
+
|
|
1057
|
+
# Parent drops evse-1 mid-flight
|
|
1058
|
+
_push_state(ctrl, "panel-1", "init")
|
|
1059
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1060
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1061
|
+
|
|
1062
|
+
assert "evse-1" not in ctrl.devices
|
|
1063
|
+
assert "bess-1" in ctrl.devices
|
|
1064
|
+
assert removed == ["evse-1"]
|
|
1065
|
+
# 4 unsubscribe calls for evse-1's four filters
|
|
1066
|
+
unsub_topics = {c[0][0] for c in mock_client.unsubscribe.call_args_list}
|
|
1067
|
+
assert unsub_topics == _filters_for("evse-1")
|
|
1068
|
+
|
|
1069
|
+
def test_grandchild_dropped_recursively(self, mock_paho):
|
|
1070
|
+
"""Removing a middle device drops its descendants too."""
|
|
1071
|
+
ctrl, _ = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1072
|
+
removed = []
|
|
1073
|
+
ctrl.set_on_device_removed_callback(lambda d: removed.append(d.device_id))
|
|
1074
|
+
ctrl.start_discovery()
|
|
1075
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1076
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1077
|
+
_push_description(
|
|
1078
|
+
ctrl,
|
|
1079
|
+
"bess-1",
|
|
1080
|
+
{
|
|
1081
|
+
"homie": "5.0",
|
|
1082
|
+
"root": "panel-1",
|
|
1083
|
+
"parent": "panel-1",
|
|
1084
|
+
"children": ["mid-1"],
|
|
1085
|
+
},
|
|
1086
|
+
)
|
|
1087
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1088
|
+
_push_description(
|
|
1089
|
+
ctrl,
|
|
1090
|
+
"mid-1",
|
|
1091
|
+
{
|
|
1092
|
+
"homie": "5.0",
|
|
1093
|
+
"root": "panel-1",
|
|
1094
|
+
"parent": "bess-1",
|
|
1095
|
+
},
|
|
1096
|
+
)
|
|
1097
|
+
_push_state(ctrl, "mid-1", "ready")
|
|
1098
|
+
|
|
1099
|
+
# Parent drops bess-1 — mid-1 must go too
|
|
1100
|
+
_push_state(ctrl, "panel-1", "init")
|
|
1101
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0"})
|
|
1102
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1103
|
+
|
|
1104
|
+
assert "bess-1" not in ctrl.devices
|
|
1105
|
+
assert "mid-1" not in ctrl.devices
|
|
1106
|
+
# Leaves-first ordering: mid-1 fires before bess-1
|
|
1107
|
+
assert removed == ["mid-1", "bess-1"]
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
class TestTreeRootedDescriptionRace:
|
|
1111
|
+
"""SDK-gsn: retained $state=ready may arrive before retained $description.
|
|
1112
|
+
|
|
1113
|
+
paho delivers retained messages in subscription order, and we subscribe
|
|
1114
|
+
to $state before $description in _subscribe_device_topics. So on initial
|
|
1115
|
+
connect to a broker holding both retained, the state-edge reconcile in
|
|
1116
|
+
_on_state_message can fire while the device's description is still None,
|
|
1117
|
+
seeing zero children and subscribing to nothing. The fix re-runs reconcile
|
|
1118
|
+
from _on_description_message when the device is already ready.
|
|
1119
|
+
"""
|
|
1120
|
+
|
|
1121
|
+
def test_state_before_description_still_reconciles(self, mock_paho):
|
|
1122
|
+
"""Retained $state=ready arrives FIRST, then $description with children."""
|
|
1123
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1124
|
+
ctrl.start_discovery()
|
|
1125
|
+
# State arrives first — at this moment description is None, reconcile
|
|
1126
|
+
# finds zero children and is effectively a no-op.
|
|
1127
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1128
|
+
assert "bess-1" not in ctrl.devices
|
|
1129
|
+
mock_client.subscribe.reset_mock()
|
|
1130
|
+
|
|
1131
|
+
# Description arrives second — must trigger a fresh reconcile that
|
|
1132
|
+
# sees the now-current children list.
|
|
1133
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1", "evse-1"]})
|
|
1134
|
+
|
|
1135
|
+
assert "bess-1" in ctrl.devices
|
|
1136
|
+
assert "evse-1" in ctrl.devices
|
|
1137
|
+
topics = {c[0][0] for c in mock_client.subscribe.call_args_list}
|
|
1138
|
+
assert _filters_for("bess-1") <= topics
|
|
1139
|
+
assert _filters_for("evse-1") <= topics
|
|
1140
|
+
|
|
1141
|
+
def test_description_only_acts_when_ready(self, mock_paho):
|
|
1142
|
+
"""A $description arriving while $state=init must NOT reconcile."""
|
|
1143
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1144
|
+
ctrl.start_discovery()
|
|
1145
|
+
_push_state(ctrl, "panel-1", "init")
|
|
1146
|
+
mock_client.subscribe.reset_mock()
|
|
1147
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1148
|
+
|
|
1149
|
+
# State is still init — description-driven reconcile must be gated
|
|
1150
|
+
assert "bess-1" not in ctrl.devices
|
|
1151
|
+
assert mock_client.subscribe.call_count == 0
|
|
1152
|
+
|
|
1153
|
+
def test_repeat_description_in_ready_is_idempotent(self, mock_paho):
|
|
1154
|
+
"""A second $description with unchanged children must not re-subscribe."""
|
|
1155
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1156
|
+
ctrl.start_discovery()
|
|
1157
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1158
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1159
|
+
# bess-1's tree is established — reset and re-deliver the same
|
|
1160
|
+
# description (e.g. a controller resubscribe). No subscription churn.
|
|
1161
|
+
mock_client.subscribe.reset_mock()
|
|
1162
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1163
|
+
|
|
1164
|
+
assert mock_client.subscribe.call_count == 0
|
|
1165
|
+
|
|
1166
|
+
def test_grandchild_race_via_intermediate(self, mock_paho):
|
|
1167
|
+
"""The race recurs at every level — bess-1's children list may also
|
|
1168
|
+
arrive after its $state=ready. The same fix must cover descendants."""
|
|
1169
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1170
|
+
ctrl.start_discovery()
|
|
1171
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1172
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1173
|
+
# bess-1 announces ready first, description second
|
|
1174
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1175
|
+
assert "mid-1" not in ctrl.devices
|
|
1176
|
+
mock_client.subscribe.reset_mock()
|
|
1177
|
+
_push_description(
|
|
1178
|
+
ctrl,
|
|
1179
|
+
"bess-1",
|
|
1180
|
+
{
|
|
1181
|
+
"homie": "5.0",
|
|
1182
|
+
"root": "panel-1",
|
|
1183
|
+
"parent": "panel-1",
|
|
1184
|
+
"children": ["mid-1"],
|
|
1185
|
+
},
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
assert "mid-1" in ctrl.devices
|
|
1189
|
+
topics = {c[0][0] for c in mock_client.subscribe.call_args_list}
|
|
1190
|
+
assert _filters_for("mid-1") <= topics
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
class TestTreeRootedReconnect:
|
|
1194
|
+
def test_reconnect_resets_devices_and_rewalks(self, mock_paho):
|
|
1195
|
+
"""On reconnect: registry is reset; retained state re-cascades the tree."""
|
|
1196
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1197
|
+
ctrl.start_discovery()
|
|
1198
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1199
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1200
|
+
_push_description(ctrl, "bess-1", {"homie": "5.0", "root": "panel-1", "parent": "panel-1"})
|
|
1201
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1202
|
+
assert "bess-1" in ctrl.devices
|
|
1203
|
+
|
|
1204
|
+
# Simulate reconnect: paho re-subscribes our filters; controller
|
|
1205
|
+
# resets its registry so the retained ready triggers init→ready.
|
|
1206
|
+
ctrl._on_connect()
|
|
1207
|
+
|
|
1208
|
+
# bess-1 is gone from the in-memory registry but root entry exists
|
|
1209
|
+
assert set(ctrl.devices.keys()) == {"panel-1"}
|
|
1210
|
+
assert ctrl.devices["panel-1"].state is None
|
|
1211
|
+
|
|
1212
|
+
# Retained $state/$description re-arrive on the recovered subscriptions
|
|
1213
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1214
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1215
|
+
_push_description(ctrl, "bess-1", {"homie": "5.0", "root": "panel-1", "parent": "panel-1"})
|
|
1216
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1217
|
+
|
|
1218
|
+
assert set(ctrl.devices.keys()) == {"panel-1", "bess-1"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|