Skip to content

Commit 47e33be

Browse files
committed
feat: type Controller's mqttc by what the SDK calls on an injected client
Closes #8. mqttc was annotated with the concrete MqttClient, which nominally admits only that class or a subclass. It works today only because ebus-mqtt-client ships no py.typed, so downstream type checkers resolve the import to Any. When that marker lands the annotation starts meaning what it says, and a consumer injecting its own client needs a cast or a type-ignore to use a feature whose purpose is supplying one. Adds MqttTransport, a runtime_checkable Protocol of the three members the SDK actually calls on an injected client — publish, subscribe, unsubscribe — and widens the parameter to it. Signatures mirror MqttClient exactly, so MqttClient satisfies it unchanged and every existing call site keeps type-checking. Deliberately not the full client surface. An injected client is never started or stopped: _connect_broker returns early when mqttc is already set, so the start() beside from_config is unreachable, and stop() is behind if self._owns_client. is_connected, is_running and publish_and_flush are Device/Property-path members with no injection point. Typing the parameter with those would oblige consumers to implement methods the SDK provably never calls on their object. Two supporting edits, both behaviour-preserving: _connect_broker binds the SDK-built client to a local of the concrete type so start() resolves, and Controller keeps that client in _owned_client so stop() has a typed handle. Assignment order in _connect_broker is unchanged, so a start() that raises leaves self.mqttc exactly as it did before.
1 parent 24e1a3c commit 47e33be

4 files changed

Lines changed: 141 additions & 6 deletions

File tree

src/ebus_sdk/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@
7878
# MQTT client
7979
from ebus_mqtt_client import MqttClient
8080

81+
# Structural type for a caller-supplied MQTT client
82+
from ebus_sdk.transport import MqttTransport
83+
8184
__version__ = "0.14.0"
8285

8386
__all__ = [
@@ -130,4 +133,5 @@
130133
"CONNECTION_NODE_TYPE",
131134
# MQTT
132135
"MqttClient",
136+
"MqttTransport",
133137
]

src/ebus_sdk/homie.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ 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 MqttTransport
66+
6567
# Optional: JSONSchema validation of a `json` property's `$format`. Kept optional
6668
# (see `ebus-sdk[validation]`) so a constrained build can omit it; absent it,
6769
# validation is gracefully skipped (see `validate_json_format`).
@@ -2176,7 +2178,7 @@ def __init__(
21762178
device_id: Optional[str] = None,
21772179
root_device_id: Optional[str] = None,
21782180
qos: int = EBUS_HOMIE_MQTT_QOS,
2179-
mqttc: Optional[MqttClient] = None,
2181+
mqttc: Optional[MqttTransport] = None,
21802182
):
21812183
"""
21822184
Initialize a Homie Controller
@@ -2221,8 +2223,13 @@ def __init__(
22212223
# Bring-your-own-transport (SDK-61t.6): an injected client is used as-is
22222224
# and its lifecycle stays the caller's; a None here means the SDK
22232225
# constructs, starts, and owns the client (the default, unchanged path).
2224-
self.mqttc = mqttc
2226+
self.mqttc: Optional[MqttTransport] = mqttc
22252227
self._owns_client = mqttc is None
2228+
# The SDK-constructed client, kept as its concrete type so start()/stop() —
2229+
# which exist only on a client we own — remain callable. Stays None for an
2230+
# injected client, which is what makes "never started, never stopped" a
2231+
# property of the types rather than a promise in a comment.
2232+
self._owned_client: Optional[MqttClient] = None
22262233
self.devices = {} # {device_id: DiscoveredDevice}
22272234
# Tree-rooted mode: {parent_device_id: set_of_subscribed_child_ids}.
22282235
# Authoritative record of what we've subscribed for under each parent,
@@ -2257,12 +2264,18 @@ def _connect_broker(self) -> None:
22572264

22582265
client_id = f"homie-controller-{uuid.uuid4()}"
22592266
try:
2260-
self.mqttc = MqttClient.from_config(
2267+
# Bound to a local of the concrete type so start() resolves — self.mqttc is
2268+
# MqttTransport, which deliberately has no start(). Assignment order is
2269+
# unchanged from before: both references are set before start(), so a
2270+
# start() that raises leaves self.mqttc set exactly as it did previously.
2271+
client = MqttClient.from_config(
22612272
mqtt_cfg=self._mqtt_cfg,
22622273
client_id=client_id,
22632274
on_connect_callback=partial(self._on_connect),
22642275
)
2265-
self.mqttc.start(blocking=False)
2276+
self._owned_client = client
2277+
self.mqttc = client
2278+
client.start(blocking=False)
22662279
logger.info(f"reason=controllerConnected,clientID={client_id}")
22672280
except Exception as e:
22682281
logger.exception(f"reason=controllerConnectException,error={e}")
@@ -2854,9 +2867,13 @@ def stop(self) -> None:
28542867
"""
28552868
if self.mqttc:
28562869
logger.info(f"reason=stoppingController,deviceCount={len(self.devices)}")
2857-
if self._owns_client:
2858-
self.mqttc.stop()
2870+
# Stops via the owned handle, never via self.mqttc: an injected client has no
2871+
# stop() in its contract, and _owned_client is None precisely when one was
2872+
# injected. Same condition as before — _owns_client still decides.
2873+
if self._owns_client and self._owned_client is not None:
2874+
self._owned_client.stop()
28592875
self.mqttc = None
2876+
self._owned_client = None
28602877
# Release DiscoveredDevice objects and their property dicts
28612878
self.devices.clear()
28622879
self._subscribed_children.clear()

src/ebus_sdk/transport.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Structural type for the MQTT transport the SDK is given.
2+
3+
Its own module because it is public API rather than an internal detail: a consumer who
4+
cannot name the type gains nothing from the widening, so it is re-exported from ``ebus_sdk``
5+
beside ``MqttClient``. Keeping a small public type out of a ~2,900-line module is the only
6+
reason it is not in ``homie.py``; nothing else imports it today.
7+
"""
8+
9+
from typing import Any, Protocol, runtime_checkable
10+
11+
12+
@runtime_checkable
13+
class MqttTransport(Protocol):
14+
"""What the SDK calls on a *caller-supplied* MQTT client.
15+
16+
Three members, deliberately — not the full ``MqttClient`` surface.
17+
18+
An injected client is never started or stopped by the SDK, and that is an invariant the
19+
code enforces rather than a convention:
20+
21+
* ``Controller._connect_broker`` returns immediately when ``self.mqttc`` is already set,
22+
so the ``start()`` beside ``MqttClient.from_config(...)`` is unreachable for an
23+
injected client.
24+
* ``Controller.stop`` calls ``stop()`` only behind ``if self._owns_client``, which is
25+
``mqttc is None`` fixed at construction.
26+
27+
``is_connected``, ``is_running`` and ``publish_and_flush`` are likewise absent because
28+
nothing on the ``Controller`` path calls them — they belong to the ``Device`` /
29+
``Property`` path, which has no injection point.
30+
31+
The narrowness is deliberate rather than incidental. Widening this to the full client
32+
surface would type the injection point as *something the SDK may start and stop* — the
33+
opposite of the ownership guarantee above — and would oblige every consumer to implement
34+
two lifecycle methods the SDK provably never calls on their object; for a host supplying
35+
a connection whose lifecycle it already manages elsewhere, those stubs are pure ceremony.
36+
37+
If ``Device`` gains an injection point later it needs a wider contract than this (it does
38+
call ``is_connected``, ``is_running`` and ``publish_and_flush``) but still not
39+
``start``/``stop``. That would be a second protocol deriving from this one rather than an
40+
edit to this one.
41+
42+
Signatures mirror ``ebus_mqtt_client.MqttClient`` exactly, including the ``Any`` on
43+
``subscribe``'s callback, so that ``MqttClient`` satisfies this unchanged. Returns are
44+
``object`` because every call site in the SDK discards them.
45+
"""
46+
47+
def publish(self, topic: str, data: str, qos: int = 1, retain: bool = False) -> object: ...
48+
49+
def subscribe(self, sub: str, param: Any, qos: int = 1) -> object: ...
50+
51+
def unsubscribe(self, sub: str) -> object: ...

tests/test_controller.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,3 +1303,66 @@ def test_owned_client_is_constructed_started_and_stopped(self):
13031303
client.start.assert_called_once() # SDK starts an owned client
13041304
ctrl.stop()
13051305
client.stop.assert_called_once() # and stops it
1306+
1307+
1308+
class TestMqttTransportProtocol:
1309+
"""The injection point is typed by what the SDK calls on an injected client (#8)."""
1310+
1311+
def test_mqtt_client_satisfies_the_protocol(self):
1312+
"""The concrete client must keep satisfying the widened annotation."""
1313+
from ebus_mqtt_client import MqttClient as ConcreteClient
1314+
1315+
from ebus_sdk import MqttTransport
1316+
1317+
assert issubclass(ConcreteClient, MqttTransport)
1318+
1319+
def test_protocol_is_exported_from_the_package_root(self):
1320+
"""A consumer who cannot name the type gets nothing from the widening."""
1321+
import ebus_sdk
1322+
1323+
assert "MqttTransport" in ebus_sdk.__all__
1324+
assert ebus_sdk.MqttTransport is not None
1325+
1326+
def test_a_three_member_client_is_a_valid_transport(self):
1327+
"""publish / subscribe / unsubscribe is the whole injected-client contract.
1328+
1329+
Deliberately implements nothing else — no start, stop, is_connected, is_running
1330+
or publish_and_flush — because the SDK never calls those on an injected client.
1331+
"""
1332+
from ebus_sdk import MqttTransport
1333+
1334+
class Minimal:
1335+
def publish(self, topic, data, qos=1, retain=False):
1336+
return None
1337+
1338+
def subscribe(self, sub, param, qos=1):
1339+
return None
1340+
1341+
def unsubscribe(self, sub):
1342+
return True
1343+
1344+
client = Minimal()
1345+
assert isinstance(client, MqttTransport)
1346+
1347+
ctrl = Controller(mqttc=client)
1348+
ctrl.start_discovery()
1349+
ctrl.stop() # must not reach start()/stop() on a client that has neither
1350+
1351+
def test_owned_client_handle_is_none_when_injected(self):
1352+
"""`_owned_client` is what makes 'never stopped' structural rather than promised."""
1353+
fake = MagicMock()
1354+
ctrl = Controller(mqttc=fake)
1355+
assert ctrl._owned_client is None
1356+
ctrl.stop()
1357+
fake.stop.assert_not_called()
1358+
1359+
def test_owned_client_handle_is_set_and_cleared_for_an_sdk_built_client(self):
1360+
with patch("ebus_sdk.homie.MqttClient.from_config") as mock_from_config:
1361+
client = MagicMock()
1362+
client.sub_callbacks = {}
1363+
mock_from_config.return_value = client
1364+
ctrl = Controller(mqtt_cfg={"host": "x"})
1365+
assert ctrl._owned_client is client
1366+
ctrl.stop()
1367+
client.stop.assert_called_once()
1368+
assert ctrl._owned_client is None

0 commit comments

Comments
 (0)