Skip to content

feat: type Controller's mqttc by what the SDK calls on an injected client - #12

Merged
dcj merged 4 commits into
electrification-bus:mainfrom
cayossarian:fix/mqtt-transport-protocol
Aug 2, 2026
Merged

feat: type Controller's mqttc by what the SDK calls on an injected client#12
dcj merged 4 commits into
electrification-bus:mainfrom
cayossarian:fix/mqtt-transport-protocol

Conversation

@cayossarian

@cayossarian cayossarian commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Closes #8. Two module-level choices are still open at the end, each with a recommendation,
so agreeing is a merge rather than a task.

Updated after review — the rename is in (266e329)

Your split is better than what I proposed, and for a reason I had missed: putting the
general name on the general surface means a Device contract derives from the base, so
unsubscribe is never inherited. The wart doesn't get worn, it stops existing. The docstring
line "a second protocol deriving from this one" is now literally true rather than
aspirational.

  • MqttTransportpublish, subscribe. The surface both injection points share.
  • MqttControllerTransport(MqttTransport) — adds unsubscribe. What this PR wires.
  • Both runtime_checkable, both exported; MqttClient satisfies each unchanged.

A Device-side contract is deliberately absent — it lands with the Device seam.
test_the_controller_contract_derives_from_the_shared_base pins the property that makes the
split worth doing: a two-member client satisfies MqttTransport and is rejected by
MqttControllerTransport.

The failure, reproduced

ebus-mqtt-client ships no py.typed, so MqttClient currently resolves to Any
downstream and Optional[MqttClient] accepts anything. That is the only reason injection
type-checks today. Dropping a py.typed into the installed ebus-mqtt-client simulates the
release #8 anticipates:

# consumer code: a client implementing publish / subscribe / unsubscribe
controller = Controller(mqtt_cfg={}, mqttc=HostOwnedClient())
BEFORE (main @ bd060d9):  error: Argument "mqttc" to "Controller" has incompatible type
                          "HostOwnedClient"; expected "MqttClient | None"  [arg-type]
AFTER:                    Success: no issues found in 1 source file

Re-run after the rename, so it is evidence about the merged shape rather than the first one.

Where the three surfaces come from

I mapped every MQTT-client call site in homie.py and ha/bridge.py to its enclosing class
before choosing. The same table derives your split: publish and subscribe appear on both
paths, unsubscribe only on the Controller path, is_connected / is_running only on the
Device path, start / stop only when the SDK owns the client.

Line numbers below are main @ bd060d9 (0.15.0), not this branch — evidence about
existing code. On the branch head they shift by +2 for Property/Device sites (the added
import) and +13 for Controller sites (that import, plus the _owned_client block and the
_connect_broker rewrite); re-verified after the rename. The start/stop snippets below
are the two lines this diff rewrites.

Member Controller path (injectable) Device / Node / Property path
publish :2742, :2811, bridge.py:295 :787, :834, :1656, :1664, :1730, :1869
subscribe ×8 :2392:2535, bridge.py:176 :957
unsubscribe :2609, bridge.py:211
is_connected :1473, :1501
is_running :690, :744, :821, :1455
publish_and_flush :1504
start owned only — :2311 :691, :1456
stop owned only — :2914 :1510

Under the flush change you described on #7_owns_client deciding both "flush?" and
"stop?" — that publish_and_flush row becomes owned-only too, which is what leaves a
Device contract at publish, subscribe, is_connected, is_running.

start and stop are unreachable for an injected client today, and that is enforced rather
than conventional:

2307    if self.mqttc:        # _connect_broker
2308        return            # injected → the start() at :2311 never runs
2913    if self._owns_client: # mqttc is None, fixed at construction
2914        self.mqttc.stop()

0.15.0 corroborates this from your side. Controller.__init__:2281-2284 on the disconnect
hook: "Only effective when the controller OWNS its client (constructed from mqtt_cfg); a
bring-your-own-client caller registers disconnect handling on its own client."
The hook is
passed to MqttClient.from_config(...) at :2309, inside the owned branch — so it adds no
member to the injected-client surface.

Typing the parameter with the full client surface would therefore demand five methods the SDK
provably never calls there — including two whose non-invocation is the documented guarantee
of the feature. A consumer supplying a connection whose lifecycle it manages elsewhere would
have to stub them as pure ceremony.

Signatures mirror MqttClient exactly, Any on subscribe's callback included. Returns are
object because every call site discards them.

Correcting something I said in #8

#8 claimed "annotation only, nothing existing has to be edited." That was wrong, and the
gap is small but real: with self.mqttc typed as the protocol, start() and stop() no
longer resolve on it. Two behaviour-preserving edits:

  • _connect_broker binds the SDK-built client to a local of the concrete type, so start()
    resolves. Assignment order is unchanged — both references are set before start(), so
    a start() that raises leaves self.mqttc exactly as it did before.
  • Controller keeps that client in _owned_client, giving stop() a typed handle.
    _owns_client still decides; _owned_client is None precisely when a client was
    injected, which makes "never stopped" a property of the types rather than a promise in a
    comment.

Tests

Six in TestMqttTransportProtocols: MqttClient satisfies both protocols; both are exported
from the package root (a consumer who cannot name the type gains nothing); the derivation
holds and a two-member client is rejected by the Controller contract; a deliberately
three-member client is accepted and driven through start_discovery() and stop() without
either being called on it; and _owned_client is None when injected, set and cleared when
SDK-built.

pytest: 506 passed, on Python 3.14 and on 3.10 (the declared floor — your CI runs only
3.12, so I checked the boundary explicitly). ruff check . and ruff format --check . clean
on the pinned 0.15.21.

Relationship to the transport-seam discussion

Discussion #7 argues the
same principle from the producer side. This PR is the Controller half and does not depend
on it
— every fact above comes from _owns_client, the early return in _connect_broker,
and the guarded stop(), all of which exist on main today. Noting it only so the narrowness
reads as deliberate rather than as an oversight.

Two things you might want to decide, with a recommendation for each

Module placement — recommend keeping transport.py. Only homie.py imports it today, so
homie.py would work. I'd still keep it separate, for one reason beyond the 2,906-line file:
transport.py imports nothing but typing, so it is a leaf any module can import without
cycle risk. Folding it into homie.py means anything wanting to name a transport type has
to import all of homie.pyha/bridge.py already imports from ..homie (:49) and
currently duck-types its client via getattr/hasattr (:174, :209-210); a leaf module is
what would let it annotate instead. Cheap to move if you disagree — one file, no call sites.

_owned_client vs a narrower fix — recommend keeping it. A cast or an isinstance
assert at the stop() site would be a smaller diff. I'd keep the attribute: assert is
stripped under -O, so an assert there would buy the type checker a guarantee the runtime
does not actually make, and cast checks nothing at any time. _owned_client makes "an
injected client is never stopped" true by construction — it is None precisely when one was
injected — for the cost of one Optional[MqttClient] field. Say the word if you'd rather have
the three-line version.

@cayossarian
cayossarian force-pushed the fix/mqtt-transport-protocol branch from 9a9fb96 to 47e33be Compare August 2, 2026 19:21
…ient

Closes electrification-bus#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.

@dcj dcj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting one change before merge; full rationale is in discussion #7 (the transport-seam thread). Make MqttTransport the shared base and derive the role-specific contracts from it, rather than typing the consumer surface as MqttTransport directly:

  • MqttTransport (base): publish, subscribe
  • MqttControllerTransport(MqttTransport): adds unsubscribe — this PR's injection point
  • MqttDeviceTransport(MqttTransport): adds is_connected, is_running — lands with the Device seam, not here

Two reasons. The general name goes to the general surface instead of the consumer-specific one. And because the future Device contract derives from the two-member base rather than the three-member consumer type, it never inherits unsubscribe, so the wart you flagged dissolves instead of being worn. MqttClient still satisfies all three structurally, and your docstring line about "a second protocol deriving from this one" becomes literally correct.

Everything else here is good as-is; the self.mqttc / self._owned_client split that makes "never started, never stopped" a property of the types is exactly the right move. Happy to merge as soon as the rename + base land.

…tract

MqttTransport carries publish + subscribe, the surface both injection points
share. MqttControllerTransport derives from it and adds unsubscribe, the only
member the consumer path reaches beyond that base.

A Device-side contract derives from the same base, so it picks up is_connected
and is_running without inheriting unsubscribe, which nothing on that path calls.
dcj added a commit that referenced this pull request Aug 2, 2026
#14) (#18)

Lets a root Device accept a caller-supplied MQTT client so the SDK can be
embedded in a host that owns its connection (Home Assistant: single_config_entry
makes a second SDK-owned connection impossible). Mirrors Controller's seam.

- Device(mqttc=): root-only, mutually exclusive with mqtt_cfg= and parent=.
  _owns_client = mqttc is None gates lifecycle. Typed Optional[MqttClient] for
  now (the MqttDeviceTransport protocol widening rides #12).
- start_mqtt_client() (Device and Property) no-ops for an injected client.
- stop() branches on ownership: injected publishes a plain retained
  $state=disconnected and returns without flushing or closing (non-blocking).
- publish_value()/clear_value() gate on connectivity (is_running OR is_connected)
  so a loop-owning host that never calls start() still publishes values; owned
  behavior is unchanged (connected implies running there).
- on_disconnect= is documented and warned as inert for an injected client.
- Presence-by-identity for the mqtt_cfg exclusivity guard.

Caller wires the Homie-correctness pieces from #13: set will() before connecting
and call refresh_tree() from their on-connect.

docs: README Bring-your-own-transport section; CHANGELOG [Unreleased] entries
(also backfills #13's will()/resync()).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dcj
dcj previously approved these changes Aug 2, 2026

@dcj dcj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exactly the split from discussion #7: MqttTransport is now the shared base (publish + subscribe), MqttControllerTransport derives and adds unsubscribe, and the docstring correctly notes a Device-side contract derives from the same base with is_connected/is_running and never inherits unsubscribe. The general name lands on the general surface, and the wart is gone.

Verified locally (this branch predates the 3.10-3.13 matrix, so its CI is ruff-only): full suite passes and ruff check + ruff format --check are clean. Nice split of self.mqttc (the transport) from self._owned_client (the concrete client start/stop resolve on).

Approving. The Device-side MqttDeviceTransport(MqttTransport) follow-up (plus widening Device.mqttc) is mine, on top of this once it lands.

dcj and others added 2 commits August 2, 2026 15:25
…lectrification-bus#8)

Bill's electrification-bus#12 typed the bring-your-own-transport injection points with narrow
Protocols; add the [Unreleased] entry (merged main in first so it sits with
the other unreleased entries rather than conflicting).

Co-Authored-By: Bill Flood <cayossarian@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@dcj dcj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after syncing main in and adding the CHANGELOG entry for the transport Protocol typing. Full matrix (3.10-3.13) + ruff green. Merging on Bill's behalf.

@dcj
dcj merged commit 6fbc8a1 into electrification-bus:main Aug 2, 2026
5 checks passed
dcj added a commit that referenced this pull request Aug 2, 2026
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>
dcj added a commit that referenced this pull request Aug 2, 2026
… typing)

Bumps __version__ to 0.16.0 and promotes the [Unreleased] CHANGELOG to
[0.16.0]. Carries the bring-your-own-transport work accumulated since 0.15.0:
the Device(mqttc=) producer seam (#14) with the will/refresh_tree wiring
contract (#13), and the transport Protocol typing (MqttTransport /
MqttControllerTransport #12, MqttDeviceTransport #19). README gains the BYO
section with a neutral note on the shared-connection will limit.

This is the ebus-sdk release the ebus-mqtt-client py.typed marker
(electrification-bus/ebus-mqtt-client#9) is gated on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dcj added a commit that referenced this pull request Aug 2, 2026
… typing) (#21)

Bumps __version__ to 0.16.0 and promotes the [Unreleased] CHANGELOG to
[0.16.0]. Carries the bring-your-own-transport work accumulated since 0.15.0:
the Device(mqttc=) producer seam (#14) with the will/refresh_tree wiring
contract (#13), and the transport Protocol typing (MqttTransport /
MqttControllerTransport #12, MqttDeviceTransport #19). README gains the BYO
section with a neutral note on the shared-connection will limit.

This is the ebus-sdk release the ebus-mqtt-client py.typed marker
(electrification-bus/ebus-mqtt-client#9) is gated on.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mqttc injection will stop type-checking once ebus-mqtt-client ships py.typed

2 participants