Skip to content

Commit 55f34f1

Browse files
dcjclaude
andcommitted
homie: type the Device injection point with MqttDeviceTransport
Follow-up to #14, on top of #12's MqttTransport base. Adds a Device-side structural type and mirrors #12's owned/injected client split onto Device, so "the SDK never starts or stops a client it did not build" is enforced by the types rather than a convention. - transport.py: MqttDeviceTransport(MqttTransport) adds is_connected + is_running (the members the device publish path reads); omits start/stop/publish_and_flush, which are owned-only. Exported from ebus_sdk. - Device.mqttc is retyped Optional[MqttDeviceTransport]; the SDK-built client is kept on a separate Device._owned_client (concrete MqttClient), and every owned-only call (start/stop/publish_and_flush, incl. Property.start_mqtt_client) routes through it. get_mqtt_client() returns the transport type. +4 tests (MqttDeviceTransport protocol + the _owned_client handle). Full suite and ruff green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6fbc8a1 commit 55f34f1

5 files changed

Lines changed: 112 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
66

77
### Added
88

9+
- `MqttDeviceTransport`: the structural (`Protocol`) type for a client injected into a root `Device`, re-exported from `ebus_sdk`. It derives from the shared `MqttTransport` base (`publish` + `subscribe`) and adds `is_connected` + `is_running`, the members the device publish path reads; it omits `start` / `stop` / `publish_and_flush`, which are owned-only. `Device`'s `mqttc=` parameter is now annotated with it instead of the concrete `MqttClient` (parallel to `Controller`'s `MqttControllerTransport`), so a consumer injecting its own client type-checks once `ebus-mqtt-client` ships `py.typed` (today `MqttClient` resolves to `Any`, so injection already type-checks; the protocol keeps it working past that release). The SDK keeps the client it builds on a separate `Device._owned_client` handle, so the owned-only lifecycle methods resolve on the concrete type and "the SDK never starts or stops a client it did not build" is enforced by the types rather than a convention. Additive.
910
- `Device` bring-your-own-transport: `Device(..., mqttc=<client>)` accepts a pre-built MQTT client instead of constructing one from `mqtt_cfg`, so a host that already owns its MQTT connection (e.g. a Home Assistant integration, whose MQTT integration is `single_config_entry` and forbids background threads) can publish an eBus device tree over its own transport and event loop. This is the producer-side mirror of the `Controller` seam added in 0.13.0. An injected client is used as-is: the SDK never `start()`s or `stop()`s it, and `stop()` publishes a final retained `$state=disconnected` through it and returns without flushing or closing (non-blocking on the caller's loop). Root-only, and mutually exclusive with `mqtt_cfg=` / `parent=`. Property publishing now gates on connectivity (`is_connected()`) rather than only the SDK-owned run flag, so a caller-driven client that never calls the SDK's `start()` still publishes property values (owned behavior is unchanged: there, connected implies running). Because an injected client bypasses the SDK's connect path (where the LWT, the reconnect republish, and the disconnect hook are wired), the caller wires the Homie-correctness pieces itself using `Device.will()` and `Device.refresh_tree()` (below); `on_disconnect=` is inert for an injected client (documented, and warned at construction). Additive: the default (no `mqttc`) path constructs, starts, owns, and stops a client exactly as before. Thanks to @cayossarian (GH #7). (#14)
1011
- `Device.will()`: returns the tree root's Last Will and Testament descriptor (topic ending in `/$state`, payload `lost`), exposed so a bring-your-own-transport caller can set it on their client before connecting: the will rides the MQTT CONNECT packet, so the SDK cannot add it to a client it is merely handed. The SDK uses it for any client it builds. `Device.refresh_tree()` (which republishes the whole tree) is documented as the companion on-connect hook a BYO caller wires so the retained tree re-announces after a reconnect. Additive. (#13)
1112
- `Controller.resync()`: the tree-rooted discovery bookkeeping reset, extracted from the owned-client on-connect handler and made public so a bring-your-own-transport tree-rooted controller can re-walk the tree from a broker reconnect it drives itself (an injected client bypasses the SDK's on-connect, where the reset is otherwise wired). A no-op in wildcard and single-device modes. Additive. (#13)

src/ebus_sdk/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
from ebus_mqtt_client import MqttClient
8080

8181
# Structural types for a caller-supplied MQTT client
82-
from ebus_sdk.transport import MqttControllerTransport, MqttTransport
82+
from ebus_sdk.transport import MqttControllerTransport, MqttDeviceTransport, MqttTransport
8383

8484
__version__ = "0.15.0"
8585

@@ -135,4 +135,5 @@
135135
"MqttClient",
136136
"MqttTransport",
137137
"MqttControllerTransport",
138+
"MqttDeviceTransport",
138139
]

src/ebus_sdk/homie.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class StrEnum(str, Enum):
6262
from typing import Any, Callable, List, Optional, Type, Union
6363
from ebus_mqtt_client import MqttClient
6464

65-
from ebus_sdk.transport import MqttControllerTransport
65+
from ebus_sdk.transport import MqttControllerTransport, MqttDeviceTransport
6666

6767
# Optional: JSONSchema validation of a `json` property's `$format`. Kept optional
6868
# (see `ebus-sdk[validation]`) so a constrained build can omit it; absent it,
@@ -667,7 +667,7 @@ def datatype(self) -> str:
667667
logger.debug(f"reason=getDatatype,datatype={datatype}")
668668
return datatype
669669

670-
def get_mqtt_client(self) -> MqttClient:
670+
def get_mqtt_client(self) -> Optional[MqttDeviceTransport]:
671671
"""
672672
Who calls this function, and why?
673673
"""
@@ -689,16 +689,17 @@ def start_mqtt_client(self) -> None:
689689
logger.warning(f"reason=propertyStartMqttClientNoMqttClient,propertyID={self._id}")
690690
return
691691
# Never start a caller-owned client (bring-your-own-transport): mirror the
692-
# ownership guard on Device.start_mqtt_client(). Resolve the root via
693-
# node -> device -> root; an incomplete chain falls through as owned.
692+
# ownership guard on Device.start_mqtt_client(), and start via the concrete
693+
# owned handle (start() is owned-only, off the MqttDeviceTransport surface).
694+
# Resolve the root via node -> device -> root; an incomplete chain is a no-op.
694695
node = self.node()
695696
device = node.device() if node else None
696697
root = device.root() if device else None
697-
if root is not None and not root._owns_client:
698+
if root is None or not root._owns_client or root._owned_client is None:
698699
return
699700
try:
700-
if not mqttc.is_running:
701-
mqttc.start()
701+
if not root._owned_client.is_running:
702+
root._owned_client.start()
702703
except Exception as e:
703704
logger.warning(f"reason=propertyStartMqttClientException,e={e}")
704705

@@ -1056,7 +1057,7 @@ def device(self) -> Device:
10561057
def set_device(self, device: Device) -> None:
10571058
self._device = device
10581059

1059-
def get_mqtt_client(self) -> MqttClient:
1060+
def get_mqtt_client(self) -> Optional[MqttDeviceTransport]:
10601061
device = self.device()
10611062
if not device:
10621063
logger.warning(f"reason=nodeGetMqttClientNoDevice,nodeID={self._id}")
@@ -1289,7 +1290,7 @@ def __init__(
12891290
extensions: Optional[List] = None,
12901291
description_extras: Optional[dict] = None,
12911292
mqtt_cfg: Optional[dict] = None,
1292-
mqttc: Optional[MqttClient] = None,
1293+
mqttc: Optional[MqttDeviceTransport] = None,
12931294
qos: int = EBUS_HOMIE_MQTT_QOS,
12941295
on_disconnect: Optional[Callable[[bool], None]] = None,
12951296
):
@@ -1324,8 +1325,13 @@ def __init__(
13241325
# Controller's bring-your-own-transport seam). mqttc=None is the owned path
13251326
# (the SDK builds the client from mqtt_cfg) or transport-free (mqtt_cfg=None,
13261327
# no socket). Only the root holds a client; children read root._owns_client.
1327-
self.mqttc = mqttc
1328+
self.mqttc: Optional[MqttDeviceTransport] = mqttc
13281329
self._owns_client = mqttc is None
1330+
# The SDK-constructed client, kept as its concrete type so start() / stop() /
1331+
# publish_and_flush() -- which exist only on a client we own -- stay callable.
1332+
# Stays None for an injected client, which makes "never started, never stopped"
1333+
# a property of the types rather than a promise in a comment (mirrors Controller).
1334+
self._owned_client: Optional[MqttClient] = None
13291335
self._state = None
13301336
self._qos = qos
13311337
# Optional consumer hook: called on the ROOT's MQTT (dis)connect. Only a
@@ -1487,7 +1493,7 @@ def nodes(self) -> dict:
14871493
"""
14881494
return self._nodes
14891495

1490-
def get_mqtt_client(self) -> MqttClient:
1496+
def get_mqtt_client(self) -> Optional[MqttDeviceTransport]:
14911497
"""
14921498
Return the MQTT client for this device's tree.
14931499
For root devices, returns self.mqttc. For children, ascends to root.
@@ -1510,8 +1516,10 @@ def start_mqtt_client(self) -> None:
15101516
# Bring-your-own-transport: the caller owns the client's lifecycle, so
15111517
# the SDK never starts it (it is expected to be connected already).
15121518
return
1513-
if not root.mqttc.is_running:
1514-
root.mqttc.start()
1519+
# Owned path: start via the concrete handle (start() is owned-only, off the
1520+
# MqttDeviceTransport surface). _owned_client is set whenever _owns_client is True.
1521+
if root._owned_client is not None and not root._owned_client.is_running:
1522+
root._owned_client.start()
15151523

15161524
def is_connected(self) -> bool:
15171525
"""
@@ -1566,8 +1574,8 @@ def stop(self, *, flush_timeout: float = 1.0, stop_timeout: float = 2.0) -> None
15661574
if mqttc.is_connected():
15671575
root._state = DeviceState.DISCONNECTED
15681576
state_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state"
1569-
if root._owns_client:
1570-
flushed = mqttc.publish_and_flush(
1577+
if root._owns_client and root._owned_client is not None:
1578+
flushed = root._owned_client.publish_and_flush(
15711579
state_topic, DeviceState.DISCONNECTED.value, qos=root._qos, retain=True, timeout=flush_timeout
15721580
)
15731581
logger.info(f"reason=deviceStopDisconnectedPublished,id={root._id},flushed={flushed}")
@@ -1581,9 +1589,10 @@ def stop(self, *, flush_timeout: float = 1.0, stop_timeout: float = 2.0) -> None
15811589
logger.info(f"reason=deviceStopDisconnectedPublishedInjected,id={root._id}")
15821590
else:
15831591
logger.info(f"reason=deviceStopBrokerUnreachable,id={root._id}")
1584-
if root._owns_client:
1585-
mqttc.stop(timeout=stop_timeout)
1592+
if root._owns_client and root._owned_client is not None:
1593+
root._owned_client.stop(timeout=stop_timeout)
15861594
root.mqttc = None
1595+
root._owned_client = None
15871596

15881597
def description(self) -> dict:
15891598
"""
@@ -2111,13 +2120,18 @@ def connect_broker(self) -> None:
21112120
# If we already have a mqtt client, don't reconnect...
21122121
return
21132122
try:
2114-
self.mqttc = MqttClient.from_config(
2123+
# Bind to a local of the concrete type so start() / stop() resolve later:
2124+
# self.mqttc is MqttDeviceTransport, which deliberately has neither. Both
2125+
# references are set before any start(), so behavior is unchanged.
2126+
client = MqttClient.from_config(
21152127
mqtt_cfg=self._mqtt_cfg,
21162128
client_id=self._id,
21172129
lwt=self.will(),
21182130
on_connect_callback=partial(self.on_connect),
21192131
on_disconnect_callback=self._handle_disconnect,
21202132
)
2133+
self._owned_client = client
2134+
self.mqttc = client
21212135
except Exception:
21222136
logger.exception(f"reason=deviceConnectBrokerFailed,id={self._id}")
21232137
raise

src/ebus_sdk/transport.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,27 @@ class MqttControllerTransport(MqttTransport, Protocol):
5151
5252
``is_connected``, ``is_running`` and ``publish_and_flush`` are absent because nothing on
5353
the ``Controller`` path calls them — they belong to the ``Device`` / ``Property`` path,
54-
which has no injection point today.
54+
which types its own injection point with ``MqttDeviceTransport`` below.
5555
"""
5656

5757
def unsubscribe(self, sub: str) -> object: ...
58+
59+
60+
@runtime_checkable
61+
class MqttDeviceTransport(MqttTransport, Protocol):
62+
"""What the SDK calls on a client injected into a root ``Device``.
63+
64+
``MqttTransport`` plus ``is_connected()`` and the ``is_running`` attribute, which the
65+
device publish path reads to gate publishing on connectivity. Like
66+
``MqttControllerTransport`` it omits ``start`` / ``stop`` (and ``publish_and_flush``):
67+
those are owned-only and resolve on the concrete client the SDK builds
68+
(``Device._owned_client``), never on an injected one, so the no-start/no-stop guarantee
69+
is a property of the types rather than a promise in a comment.
70+
71+
This protocol has a data member (``is_running``), so use ``isinstance`` for runtime
72+
checks; ``issubclass`` is unsupported for protocols with non-method members.
73+
"""
74+
75+
def is_connected(self) -> bool: ...
76+
77+
is_running: bool

tests/test_homie_device.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1972,6 +1972,62 @@ def test_parent_and_empty_mqtt_cfg_are_mutually_exclusive(self, mock_paho):
19721972
Device(id="circuit-1", parent=root, mqtt_cfg={})
19731973

19741974

1975+
class TestMqttDeviceTransportProtocol:
1976+
"""The Device injection point is typed by what the SDK calls on an injected client (#14).
1977+
1978+
MqttDeviceTransport = MqttTransport (publish/subscribe) + is_connected() + is_running.
1979+
It has a data member (is_running), so use isinstance, not issubclass.
1980+
"""
1981+
1982+
def test_protocol_is_exported_from_the_package_root(self):
1983+
import ebus_sdk
1984+
1985+
assert "MqttDeviceTransport" in ebus_sdk.__all__
1986+
assert ebus_sdk.MqttDeviceTransport is not None
1987+
1988+
def test_a_minimal_client_is_a_valid_device_transport(self):
1989+
"""publish / subscribe / is_connected / is_running is the whole injected-Device
1990+
contract. Deliberately no start / stop / publish_and_flush: the SDK never calls
1991+
those on an injected client, so a minimal client that lacks them still works."""
1992+
from ebus_sdk import MqttDeviceTransport
1993+
1994+
class Minimal:
1995+
is_running = True
1996+
1997+
def publish(self, topic, data, qos=1, retain=False):
1998+
return None
1999+
2000+
def subscribe(self, sub, param, qos=1):
2001+
return None
2002+
2003+
def is_connected(self):
2004+
return True
2005+
2006+
client = Minimal()
2007+
assert isinstance(client, MqttDeviceTransport)
2008+
2009+
device = Device(id="panel-1", type="dev.test", mqttc=client)
2010+
device.stop() # must not reach start()/stop() on a client that has neither
2011+
2012+
def test_owned_client_handle_is_none_when_injected(self):
2013+
client = _mock_mqtt_client()
2014+
device = Device(id="panel-1", mqttc=client)
2015+
assert device._owned_client is None
2016+
device.start_mqtt_client() # no-op for an injected client
2017+
client.start.assert_not_called()
2018+
2019+
def test_owned_client_handle_is_set_and_cleared_for_an_sdk_built_client(self):
2020+
with patch("ebus_sdk.homie.MqttClient.from_config") as mock_from_config:
2021+
client = _mock_mqtt_client()
2022+
client.is_connected.return_value = True
2023+
mock_from_config.return_value = client
2024+
device = Device(id="panel-1", mqtt_cfg={"host": "x"})
2025+
assert device._owned_client is client
2026+
device.stop()
2027+
client.stop.assert_called_once() # stops via the owned handle
2028+
assert device._owned_client is None
2029+
2030+
19752031
class TestDeviceWithoutTransport:
19762032
"""`mqtt_cfg=None` — the declared default — builds a device tree with no transport."""
19772033

0 commit comments

Comments
 (0)