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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebus-sdk
3
- Version: 0.2.2
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.2
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.2.2"
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.2",
28
+ "ebus-mqtt-client>=0.1.6",
29
29
  "paho-mqtt>=1.5.0",
30
30
  ]
31
31
 
@@ -13,7 +13,7 @@ from setuptools import setup
13
13
 
14
14
  setup(
15
15
  name="ebus-sdk",
16
- version="0.1.10",
16
+ version="0.3.0",
17
17
  package_dir={"": "src"},
18
18
  packages=["ebus_sdk"],
19
19
  )
@@ -43,7 +43,7 @@ from .property import (
43
43
  # MQTT client
44
44
  from ebus_mqtt_client import MqttClient
45
45
 
46
- __version__ = "0.2.2"
46
+ __version__ = "0.3.0"
47
47
 
48
48
  __all__ = [
49
49
  # Homie classes
@@ -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
- # Re-subscribe to all topics on reconnect
1765
- if self.devices:
1766
- for device_id in self.devices.keys():
1767
- self._subscribe_to_device(device_id)
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
- When device_id is set, subscribes to exact topics for that single device
1774
- (no wildcard in the device-id position). Otherwise, subscribes to the
1775
- wildcard discovery topic pattern: {domain}/5/+/$state
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.device_id:
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-creates the entry in start_discovery)
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): first $state message
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
- logger.info(f"reason=deviceDiscovered,deviceID={device_id},state={payload_str},mode=singleDevice")
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.2.2
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.2
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
  ```
@@ -1,4 +1,4 @@
1
- ebus-mqtt-client>=0.1.2
1
+ ebus-mqtt-client>=0.1.6
2
2
  paho-mqtt>=1.5.0
3
3
 
4
4
  [dev]
@@ -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