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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebus-sdk
3
- Version: 0.2.2
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.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.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.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.1",
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.1"
47
47
 
48
48
  __all__ = [
49
49
  # Homie classes
@@ -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
- # 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)
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
- 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
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.device_id:
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-creates the entry in start_discovery)
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): first $state message
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
- logger.info(f"reason=deviceDiscovered,deviceID={device_id},state={payload_str},mode=singleDevice")
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.2.2
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.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,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