ebus-sdk 0.2.2__tar.gz → 0.3.0__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.0}/PKG-INFO +24 -2
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/README.md +22 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/pyproject.toml +2 -2
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/setup.py +1 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/src/ebus_sdk/__init__.py +1 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/src/ebus_sdk/homie.py +176 -40
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0/src/ebus_sdk.egg-info}/PKG-INFO +24 -2
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/src/ebus_sdk.egg-info/requires.txt +1 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/tests/test_controller.py +332 -1
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/LICENSE +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/setup.cfg +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/src/ebus_sdk/property.py +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/src/ebus_sdk.egg-info/SOURCES.txt +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/src/ebus_sdk.egg-info/dependency_links.txt +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/src/ebus_sdk.egg-info/top_level.txt +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/tests/test_homie_device.py +0 -0
- {ebus_sdk-0.2.2 → ebus_sdk-0.3.0}/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.0
|
|
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.0"
|
|
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,20 +1704,40 @@ 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
|
|
@@ -1736,6 +1756,11 @@ class Controller:
|
|
|
1736
1756
|
if auto_start:
|
|
1737
1757
|
self.start_discovery()
|
|
1738
1758
|
|
|
1759
|
+
@property
|
|
1760
|
+
def is_tree_rooted(self) -> bool:
|
|
1761
|
+
"""True if this controller was created in tree-rooted mode (SDK-o1h)."""
|
|
1762
|
+
return self.root_device_id is not None
|
|
1763
|
+
|
|
1739
1764
|
def _connect_broker(self) -> None:
|
|
1740
1765
|
"""Connect to MQTT broker"""
|
|
1741
1766
|
if self.mqttc:
|
|
@@ -1759,20 +1784,36 @@ class Controller:
|
|
|
1759
1784
|
return self._qos
|
|
1760
1785
|
|
|
1761
1786
|
def _on_connect(self) -> None:
|
|
1762
|
-
"""Called when controller connects to MQTT broker
|
|
1787
|
+
"""Called when controller connects to MQTT broker.
|
|
1788
|
+
|
|
1789
|
+
MqttClient re-subscribes its own sub_callbacks dict on reconnect, so
|
|
1790
|
+
topic-level recovery is already handled. In tree-rooted mode, the
|
|
1791
|
+
controller's own bookkeeping (devices dict, current children) is reset
|
|
1792
|
+
here so the retained $state/$description that paho delivers on the
|
|
1793
|
+
fresh subscriptions drives a clean re-walk of the tree from the root,
|
|
1794
|
+
identical to first-connect.
|
|
1795
|
+
"""
|
|
1763
1796
|
logger.info("reason=controllerOnConnect")
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1797
|
+
if self.is_tree_rooted:
|
|
1798
|
+
# Cold restart of tree-rooted bookkeeping. paho-mqtt's MqttClient
|
|
1799
|
+
# has already re-subscribed our root's four filters; retained
|
|
1800
|
+
# state/description will arrive momentarily and the state-edge
|
|
1801
|
+
# handler will reconcile descendants from scratch. Wipe the
|
|
1802
|
+
# in-memory device registry so the init→ready edge sees the
|
|
1803
|
+
# transition (the previous state would otherwise short-circuit it).
|
|
1804
|
+
self.devices = {}
|
|
1805
|
+
device = DiscoveredDevice(self.root_device_id, self.homie_domain)
|
|
1806
|
+
self.devices[self.root_device_id] = device
|
|
1768
1807
|
|
|
1769
1808
|
def start_discovery(self, homie_domain: Optional[str] = None) -> None:
|
|
1770
1809
|
"""
|
|
1771
1810
|
Start auto-discovery of Homie devices
|
|
1772
1811
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1812
|
+
Behavior depends on the constructor-selected mode:
|
|
1813
|
+
- root_device_id: tree-rooted — subscribe to root, then auto-subscribe
|
|
1814
|
+
to descendants on the root's init→ready edge (SDK-o1h).
|
|
1815
|
+
- device_id: single-device — subscribe to exact topics for that device.
|
|
1816
|
+
- neither: wildcard — subscribe to {domain}/5/+/$state.
|
|
1776
1817
|
|
|
1777
1818
|
Args:
|
|
1778
1819
|
homie_domain: Optional specific domain to monitor (default: uses instance domain)
|
|
@@ -1783,46 +1824,56 @@ class Controller:
|
|
|
1783
1824
|
|
|
1784
1825
|
domain = homie_domain or self.homie_domain
|
|
1785
1826
|
|
|
1786
|
-
if self.
|
|
1827
|
+
if self.is_tree_rooted:
|
|
1828
|
+
logger.info(f"reason=startDiscoveryTreeRooted,rootDeviceID={self.root_device_id}")
|
|
1829
|
+
# Pre-create the root entry; descendants are added as they're
|
|
1830
|
+
# discovered via the parent's $description.children
|
|
1831
|
+
device = DiscoveredDevice(self.root_device_id, domain)
|
|
1832
|
+
self.devices[self.root_device_id] = device
|
|
1833
|
+
self._subscribe_device_topics(self.root_device_id)
|
|
1834
|
+
elif self.device_id:
|
|
1787
1835
|
# Single-device mode: subscribe to exact topics, no wildcard
|
|
1788
1836
|
# in the device-id position
|
|
1789
|
-
base = f"{domain}/{EBUS_HOMIE_VERSION_MAJOR}/{self.device_id}"
|
|
1790
1837
|
logger.info(f"reason=startDiscoverySingleDevice,deviceID={self.device_id}")
|
|
1791
|
-
|
|
1792
1838
|
# Pre-create the DiscoveredDevice entry
|
|
1793
1839
|
device = DiscoveredDevice(self.device_id, domain)
|
|
1794
1840
|
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
|
-
)
|
|
1841
|
+
self._subscribe_device_topics(self.device_id)
|
|
1820
1842
|
else:
|
|
1821
1843
|
# Wildcard discovery mode (original behavior)
|
|
1822
1844
|
discovery_topic = f"{domain}/{EBUS_HOMIE_VERSION_MAJOR}/+/$state"
|
|
1823
1845
|
logger.info(f"reason=startDiscovery,topic={discovery_topic}")
|
|
1824
1846
|
self.mqttc.subscribe(discovery_topic, param=self._on_state_message, qos=self._qos)
|
|
1825
1847
|
|
|
1848
|
+
def _subscribe_device_topics(self, device_id: str) -> None:
|
|
1849
|
+
"""Subscribe to the four exact-device topic patterns for device_id.
|
|
1850
|
+
|
|
1851
|
+
Used by single-device mode, tree-rooted mode (for the root and each
|
|
1852
|
+
discovered descendant). The wildcard $state subscription path uses a
|
|
1853
|
+
different shape and bypasses this.
|
|
1854
|
+
"""
|
|
1855
|
+
base = f"{self.homie_domain}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}"
|
|
1856
|
+
self.mqttc.subscribe(
|
|
1857
|
+
f"{base}/$state",
|
|
1858
|
+
param=self._on_state_message,
|
|
1859
|
+
qos=self._qos,
|
|
1860
|
+
)
|
|
1861
|
+
self.mqttc.subscribe(
|
|
1862
|
+
f"{base}/$description",
|
|
1863
|
+
param=partial(self._on_description_message, device_id),
|
|
1864
|
+
qos=self._qos,
|
|
1865
|
+
)
|
|
1866
|
+
self.mqttc.subscribe(
|
|
1867
|
+
f"{base}/+/+",
|
|
1868
|
+
param=partial(self._on_property_message, device_id),
|
|
1869
|
+
qos=self._qos,
|
|
1870
|
+
)
|
|
1871
|
+
self.mqttc.subscribe(
|
|
1872
|
+
f"{base}/+/+/$target",
|
|
1873
|
+
param=partial(self._on_target_message, device_id),
|
|
1874
|
+
qos=self._qos,
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1826
1877
|
def _on_state_message(self, topic: str, payload: bytes) -> None:
|
|
1827
1878
|
"""
|
|
1828
1879
|
Handle device $state messages
|
|
@@ -1854,11 +1905,12 @@ class Controller:
|
|
|
1854
1905
|
# New or existing device
|
|
1855
1906
|
if device_id not in self.devices:
|
|
1856
1907
|
# New device discovered (wildcard mode only; single-device mode
|
|
1857
|
-
# pre-
|
|
1908
|
+
# and tree-rooted mode pre-create their entries in start_discovery)
|
|
1858
1909
|
logger.info(
|
|
1859
1910
|
f"reason=deviceDiscovered,deviceID={device_id},state={payload_str},knownDevices={list(self.devices.keys())}"
|
|
1860
1911
|
)
|
|
1861
1912
|
device = DiscoveredDevice(device_id, homie_domain)
|
|
1913
|
+
old_state = None
|
|
1862
1914
|
device.update_state(payload_str)
|
|
1863
1915
|
self.devices[device_id] = device
|
|
1864
1916
|
|
|
@@ -1868,10 +1920,13 @@ class Controller:
|
|
|
1868
1920
|
if self._on_device_discovered:
|
|
1869
1921
|
self._on_device_discovered(device)
|
|
1870
1922
|
elif self.devices[device_id].state is None:
|
|
1871
|
-
# Pre-created entry (single-device mode):
|
|
1923
|
+
# Pre-created entry (single-device or tree-rooted mode):
|
|
1924
|
+
# first $state message
|
|
1872
1925
|
device = self.devices[device_id]
|
|
1926
|
+
old_state = None
|
|
1873
1927
|
device.update_state(payload_str)
|
|
1874
|
-
|
|
1928
|
+
mode = "treeRooted" if self.is_tree_rooted else "singleDevice"
|
|
1929
|
+
logger.info(f"reason=deviceDiscovered,deviceID={device_id},state={payload_str},mode={mode}")
|
|
1875
1930
|
if self._on_device_discovered:
|
|
1876
1931
|
self._on_device_discovered(device)
|
|
1877
1932
|
else:
|
|
@@ -1892,8 +1947,22 @@ class Controller:
|
|
|
1892
1947
|
device.update_state(payload_str)
|
|
1893
1948
|
logger.debug(f"reason=deviceStateRefreshed,deviceID={device_id},state={payload_str}")
|
|
1894
1949
|
|
|
1950
|
+
# Tree-rooted mode: any device transitioning INTO ready is the trust
|
|
1951
|
+
# signal to act on its $description.children. Per Homie 5, only the
|
|
1952
|
+
# init→ready edge guarantees a consistent description; mid-flight
|
|
1953
|
+
# description updates while state=init are stashed but not acted on.
|
|
1954
|
+
if self.is_tree_rooted and payload_str == DeviceState.READY.value and old_state != DeviceState.READY.value:
|
|
1955
|
+
self._reconcile_descendants(device_id)
|
|
1956
|
+
|
|
1895
1957
|
def _subscribe_to_device(self, device_id: str) -> None:
|
|
1896
|
-
"""Subscribe to all topics for a discovered device
|
|
1958
|
+
"""Subscribe to all topics for a discovered device (wildcard-mode helper).
|
|
1959
|
+
|
|
1960
|
+
Wildcard discovery hears a device's $state via the {domain}/5/+/$state
|
|
1961
|
+
subscription; once it knows the device exists, it needs three more
|
|
1962
|
+
filters (description, properties, targets) to track everything else.
|
|
1963
|
+
Tree-rooted and single-device modes don't go through here — they call
|
|
1964
|
+
_subscribe_device_topics directly to get all four filters at once.
|
|
1965
|
+
"""
|
|
1897
1966
|
if not self.mqttc:
|
|
1898
1967
|
return
|
|
1899
1968
|
|
|
@@ -1923,6 +1992,73 @@ class Controller:
|
|
|
1923
1992
|
qos=self._qos,
|
|
1924
1993
|
)
|
|
1925
1994
|
|
|
1995
|
+
def _reconcile_descendants(self, device_id: str) -> None:
|
|
1996
|
+
"""Diff a device's announced children against what's subscribed (SDK-o1h).
|
|
1997
|
+
|
|
1998
|
+
Fired on the init→ready edge in tree-rooted mode. Added children get
|
|
1999
|
+
full topic subscriptions (their own retained $state/$description then
|
|
2000
|
+
cascade through this same handler, surfacing grandchildren). Removed
|
|
2001
|
+
children are unsubscribed and dropped from the registry recursively.
|
|
2002
|
+
|
|
2003
|
+
The state-edge gate (in _on_state_message) guarantees we only act when
|
|
2004
|
+
$state=ready confirms the description is current — never on a partial
|
|
2005
|
+
view stashed during $state=init.
|
|
2006
|
+
"""
|
|
2007
|
+
if not self.mqttc:
|
|
2008
|
+
return
|
|
2009
|
+
device = self.devices.get(device_id)
|
|
2010
|
+
if device is None:
|
|
2011
|
+
return
|
|
2012
|
+
|
|
2013
|
+
declared = set(device.children_ids)
|
|
2014
|
+
current = {cid for cid, d in self.devices.items() if cid != device_id and d.parent_id == device_id}
|
|
2015
|
+
|
|
2016
|
+
added = declared - current
|
|
2017
|
+
removed = current - declared
|
|
2018
|
+
|
|
2019
|
+
for child_id in added:
|
|
2020
|
+
logger.info(f"reason=treeRootedAddDescendant,parentID={device_id},childID={child_id}")
|
|
2021
|
+
# Pre-create child entry; its own retained $state/$description
|
|
2022
|
+
# arrive via the new subscriptions and drive update + cascade.
|
|
2023
|
+
if child_id not in self.devices:
|
|
2024
|
+
self.devices[child_id] = DiscoveredDevice(child_id, self.homie_domain)
|
|
2025
|
+
self._subscribe_device_topics(child_id)
|
|
2026
|
+
|
|
2027
|
+
for child_id in removed:
|
|
2028
|
+
logger.info(f"reason=treeRootedRemoveDescendant,parentID={device_id},childID={child_id}")
|
|
2029
|
+
self._unsubscribe_and_drop(child_id)
|
|
2030
|
+
|
|
2031
|
+
def _unsubscribe_and_drop(self, device_id: str) -> None:
|
|
2032
|
+
"""Recursively drop a descendant and all of its own descendants (SDK-o1h).
|
|
2033
|
+
|
|
2034
|
+
Unsubscribes the four topic filters, removes the entry from the
|
|
2035
|
+
registry, and fires on_device_removed (leaves-first so callbacks see a
|
|
2036
|
+
consistent view: when fired for a parent, its children are already
|
|
2037
|
+
gone). No-op if the device isn't tracked.
|
|
2038
|
+
"""
|
|
2039
|
+
if device_id not in self.devices:
|
|
2040
|
+
return
|
|
2041
|
+
# Snapshot before mutating: collect this device's transitive
|
|
2042
|
+
# descendants by parent_id linkage; recurse leaves-first.
|
|
2043
|
+
children = [cid for cid, d in self.devices.items() if cid != device_id and d.parent_id == device_id]
|
|
2044
|
+
for child_id in children:
|
|
2045
|
+
self._unsubscribe_and_drop(child_id)
|
|
2046
|
+
|
|
2047
|
+
device = self.devices.pop(device_id, None)
|
|
2048
|
+
if device is None:
|
|
2049
|
+
return
|
|
2050
|
+
|
|
2051
|
+
if self.mqttc:
|
|
2052
|
+
base = f"{self.homie_domain}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}"
|
|
2053
|
+
for suffix in ("$state", "$description", "+/+", "+/+/$target"):
|
|
2054
|
+
self.mqttc.unsubscribe(f"{base}/{suffix}")
|
|
2055
|
+
|
|
2056
|
+
if self._on_device_removed:
|
|
2057
|
+
try:
|
|
2058
|
+
self._on_device_removed(device)
|
|
2059
|
+
except Exception:
|
|
2060
|
+
logger.exception(f"reason=onDeviceRemovedCallbackException,deviceID={device_id}")
|
|
2061
|
+
|
|
1926
2062
|
def _on_description_message(self, device_id: str, topic: str, payload: bytes) -> None:
|
|
1927
2063
|
"""Handle device $description messages"""
|
|
1928
2064
|
if device_id not in self.devices:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebus-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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,333 @@ 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 TestTreeRootedReconnect:
|
|
1111
|
+
def test_reconnect_resets_devices_and_rewalks(self, mock_paho):
|
|
1112
|
+
"""On reconnect: registry is reset; retained state re-cascades the tree."""
|
|
1113
|
+
ctrl, mock_client = _make_controller(mock_paho, root_device_id="panel-1")
|
|
1114
|
+
ctrl.start_discovery()
|
|
1115
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1116
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1117
|
+
_push_description(ctrl, "bess-1", {"homie": "5.0", "root": "panel-1", "parent": "panel-1"})
|
|
1118
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1119
|
+
assert "bess-1" in ctrl.devices
|
|
1120
|
+
|
|
1121
|
+
# Simulate reconnect: paho re-subscribes our filters; controller
|
|
1122
|
+
# resets its registry so the retained ready triggers init→ready.
|
|
1123
|
+
ctrl._on_connect()
|
|
1124
|
+
|
|
1125
|
+
# bess-1 is gone from the in-memory registry but root entry exists
|
|
1126
|
+
assert set(ctrl.devices.keys()) == {"panel-1"}
|
|
1127
|
+
assert ctrl.devices["panel-1"].state is None
|
|
1128
|
+
|
|
1129
|
+
# Retained $state/$description re-arrive on the recovered subscriptions
|
|
1130
|
+
_push_description(ctrl, "panel-1", {"homie": "5.0", "children": ["bess-1"]})
|
|
1131
|
+
_push_state(ctrl, "panel-1", "ready")
|
|
1132
|
+
_push_description(ctrl, "bess-1", {"homie": "5.0", "root": "panel-1", "parent": "panel-1"})
|
|
1133
|
+
_push_state(ctrl, "bess-1", "ready")
|
|
1134
|
+
|
|
1135
|
+
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
|