Skip to content

fix: log a missing MQTT client at debug when the tree is transport-free by design - #20

Merged
dcj merged 1 commit into
electrification-bus:mainfrom
cayossarian:fix/transport-free-log-severity
Aug 2, 2026
Merged

dcj merged 1 commit into
electrification-bus:mainfrom
cayossarian:fix/transport-free-log-severity

Conversation

@cayossarian

@cayossarian cayossarian commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Closes #11. The debug/warning split you confirmed, with the predicate exactly as you
specified it.

The predicate

Device._transport_free() — the root holds no client and was given no config to build one
from:

root = self.root()
return root.mqttc is None and root._mqtt_cfg is None

_mqtt_cfg is the term that separates the two cases. A root told nothing is passive by
request; a root told how to build a client and holding none is broken. That is the "you
forgot to start the root" warning the issue is careful to preserve, and it stays exactly as
loud as it was.

Property and Node resolve it through node -> device -> root, mirroring the walk already
in Property.start_mqtt_client. An incomplete chain falls through as not transport-free,
so a half-built tree stays loud rather than going quiet — silencing a detached property would
hide a real bug.

Before / after

Same 31-device / 120-property transport-free tree, same workload (build, set_state(READY),
publish every property, subscribe every settable):

WARNING INFO DEBUG
main @ 7518c7a 2,553 633 0
this branch 0 0 3,186

Identical event count — nothing is dropped, only reclassified, so DEBUG still shows every
one. The blunt workaround (logging.getLogger("homie").setLevel(logging.ERROR)) is no longer
needed, which matters because it also hid the genuine warnings.

The twelve sites

All now route through one module-level helper, so the severity rule is stated once:

def _log_missing_client(message: str, *, by_design: bool, level: int = logging.WARNING) -> None:
    logger.log(logging.DEBUG if by_design else level, message)

propertyGetMqttClient, propertyStartMqttClient, propertyPublishValue,
propertyClearValue, propertySetSubscribe, nodeGetMqttClient, deviceGetMqttClient,
deviceStartMqttClient, deviceStop, deviceDeleteAllFromMqtt, deviceClearTopic,
devicePublish.

One judgement call worth your eye

devicePublishNoMqttClient was already info on main, not warning. Transport-free drops
it to debug with the rest, but the expected-a-client case keeps info rather than being
promoted — reading "WARNING otherwise" literally would have made one site louder than it is
today, which is a behaviour change the issue did not ask for. That is what the level=
parameter expresses. Say the word if you'd rather have it uniform.

Bring-your-own-transport is not transport-free

#14 landed after the issue was filed, so worth stating explicitly: an injected client sets
mqttc, so the predicate is False and a missing client on that path still warns. That is
the right answer — the caller handed a client over, so its absence is an anomaly, not a
request. There is a test pinning it.

Rebased onto #19. The predicate reads mqttc and _mqtt_cfg, which #19 retyped without
changing their meaning, so it is unaffected; _owned_client is deliberately not part of it,
since an SDK-built client that is missing is exactly the case that should stay loud.

Tests

Six in TestTransportFreeLogSeverity: no WARNING anywhere in a transport-free tree while
DEBUG records are present; a config-bearing root still warns; an injected client is not
transport-free; the predicate resolves identically from Device, Node and Property; a
detached property stays loud; and devicePublish keeps info when a client was expected.

pytest: 533 passed, on 3.14 and on 3.10. ruff check . and ruff format --check .
clean.

Also confirmed while here

Thanks for the 3.10-3.13 matrix in #16 — that closes the gap where the declared floor wasn't
exercised by CI. This branch is green across it.

…ee by design

A tree built with mqtt_cfg=None has no client because that is what was asked
for, so every traversal reports one: a 31-device tree emitted 1,593 WARNING
lines saying only that the caller got what they requested.

_transport_free() is true when the root holds no client and was given no config
to build one from. The twelve NoMqttClient sites log at debug when it holds and
keep their previous severity otherwise, so "you forgot to start the root" stays
as loud as it was. Bring-your-own-transport is not transport-free: the client is
present, so its absence would still be an anomaly.

Property and Node resolve the predicate through node -> device -> root, mirroring
the walk already in Property.start_mqtt_client. An incomplete chain falls through
as not transport-free, so a half-built tree stays loud rather than going quiet.

Closes #11.
@cayossarian
cayossarian force-pushed the fix/transport-free-log-severity branch from 8fd2286 to b649eb5 Compare August 2, 2026 22:50

@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.

Clean, well-measured logging-severity fix (Closes #11). Verified locally: merges conflict-free with #19 and the combined suite is green (533 passed, ruff clean); checked that #19's connectivity gate and this PR's _log_missing_client helper compose correctly in publish_value. Merging on Bill's behalf; bundling into the 0.16.0 release.

@dcj
dcj merged commit 011fb6b into electrification-bus:main Aug 2, 2026
10 checks passed
dcj added a commit that referenced this pull request Aug 7, 2026
* fix: publish a device's $state after its children in refresh_tree

refresh_tree() walked the tree pre-order, publishing a device completely --
description, nodes, state -- before recursing. The root therefore announced
$state=ready while the children its own $description names had published
nothing at all. On a 37-device enclosure tree the root went ready at publish 1
and the last child at publish 73.

This is the normal path, not an edge case: on_connect calls refresh_tree() for
SDK-owned clients on both the initial connect and every reconnect. Homie 5
invites a controller to gate on the root's $state, and one that does proceeded
against a tree whose children had not described themselves. After a broker
restart a previously-healthy consumer re-read a root claiming ready with
nothing under it.

Description and nodes now publish first, then descendants, then this device's
own state. The set of messages is unchanged and only the order differs: on the
same tree, 74 publishes and 74 unique topics before and after, identical topic
sets, with the root's $state moving from index 1 to index 73.

The regression test fails on main and passes here. The existing suite passes
either way -- no test asserted the old order, which is why it survived.

* review fixups: ruff format, drop orphaned _publish_self, depth-2 order assertion

- ruff format collapse on child_states (the only CI blocker; our pin is 0.15.21)
- delete Device._publish_self(), which this change left with zero callers and a
  docstring still claiming it serves the reconnect cascade
- assert the state-after-children rule at depth 2 in the existing three-level
  test: a fix that reordered only the root passed all 546 tests as written
- reframe the comment and CHANGELOG entry as a producer-side narrowing rather
  than a guarantee consumers may build on, and add the LWT/atomic-commit reason
  (a lost root makes every child lost, so the final publish is one atomic flip)
- credit @cayossarian in the CHANGELOG, matching #10/#12/#20

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Donald Clark Jackson <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
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.

Transport-free device tree logs a missing client at WARNING, thousands of times

2 participants