feat: declare_lost() and stop(announce=False) - #52
Merged
Merged
Conversation
This was referenced Aug 13, 2026
Device modeled three teardowns and implemented one. Graceful shutdown had stop(), ungraceful death had the will, and deliberate death had nothing: a producer that knew it was failing could announce `disconnected`, which is a lie, or reach around the SDK to the concrete client. DeviceState.LOST was published nowhere in homie.py except inside the will() descriptor, and the will fires only on an UNCLEAN disconnect, which the clean disconnect stop() performs deliberately suppresses. declare_lost() is tree-level, like will() and stop(). It publishes the ROOT's $state, which per the Homie 5 effective-state rule covers every descendant in one publish, and it emits exactly the topic and payload will() describes so the declared and will-driven paths cannot drift. To mark ONE device lost (a proxy whose single upstream vanished), set_state(DeviceState.LOST) on that device remains the right call; declare_lost() would blank the whole tree's liveness. doc/building-a-proxy.md already told proxy authors to use set_state, and now says which of the two applies rather than being silently superseded. The state move and the publish happen together, and the move is unconditional: publishing a state the Device does not hold is exactly how a later refresh_tree() silently republishes `ready` over it. It returns whether $state actually moved, reusing set_state's True-changed/False-already-there convention; on an injected transport that is the caller's cue to drain, and it is deliberately not a delivery signal, which it could not honestly be there. Owned clients flush; injected clients queue on the caller's loop, since publish_and_flush is owned-only and off the MqttDeviceTransport surface. Publishing is skipped when the broker is unreachable, because a transport that queues while offline could deliver a stale `lost` long after recovery. stop(announce=False) is the counterpart: tear down without publishing anything. The state move now lives inside the announcing branch, so it cannot overwrite a just-declared `lost`. Named `announce` rather than the `graceful` a downstream reached for, because "graceful" conflates the announcement with the bounded clean disconnect; the teardown stays bounded and clean in both modes. Unpaired it leaves whatever was published last, typically a stale `ready`, and nothing corrects that, so the docstring and README say so. Neither addition touches the injected-transport surface: both only skip or perform a publish, never add a call, so the no-start/no-stop guarantee that MqttDeviceTransport encodes in the type system is unchanged. Reported by @cayossarian, whose async-transport drain sequence shaped the contract, and adopted by ebus-panel-sim in place of the reach-around that produced four separate downstream bugs. Closes #46. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #46.
Devicemodeled three teardowns and implemented one:$statedisconnectedDevice.stop()lostlostDeviceState.LOSTwas published nowhere inhomie.pyexcept inside thewill()descriptor, and the clean disconnectstop()performs deliberately suppresses the will. So a producer that knew it was dying could announcedisconnected, which is a lie, or reach around the SDK to its client.ebus-panel-simdid the latter and shipped four bugs downstream of that one reach-around.declare_lost() -> boolTree-level, like
will()andstop(): it publishes the ROOT's$state, which per the Homie 5 effective-state rule covers every descendant in one publish, and it emits exactly the topic and payloadwill()describes so the declared and will-driven paths cannot drift. There is a test asserting that equality directly, mirroring the existingtest_will_matches_the_owned_client_lwt.To mark ONE device lost (a proxy whose single upstream vanished),
set_state(DeviceState.LOST)on that device remains the right call.doc/building-a-proxy.mdalready told proxy authors exactly that, so it now says which of the two applies rather than being silently superseded by a second documented way to do the same thing.The state move and the publish happen together, and the move is unconditional. Publishing a state the
Devicedoes not hold is precisely how a laterrefresh_tree()republishesreadyover it — issue #46's downstream bug 4.The return is "did
$statemove", reusingset_state's True-changed/False-already-there convention as suggested in the thread. On an injected transport aTruefrom a connected tree is the caller's cue to drain. It is deliberately not a delivery signal, which it could not honestly be there. Publishing is skipped entirely when the broker is unreachable, since a transport that queues while offline could deliver a stalelostlong after recovery; the state still moves and the next connect republishes it.Owned clients flush (bounded by
flush_timeout); injected clients queue on the caller's loop, sincepublish_and_flushis owned-only and off theMqttDeviceTransportsurface. It does not stop the client.stop(announce=False)Tears down without publishing anything. The
_state = DISCONNECTEDassignment now lives inside the announcing branch, so it cannot overwrite a just-declaredlost— pinned bytest_announce_false_leaves_a_declared_lost_state_intact.Named
announcerather than thegracefulpanel-sim reached for, because "graceful" conflates the announcement with the bounded clean disconnect: the teardown stays bounded and clean in both modes, and only the announcement differs. Flagging that explicitly since panel-sim is adopting this in place of its local version.Unpaired it leaves whatever was published last, typically a stale
ready, and nothing corrects it. The docstring and README say so plainly rather than leaving it to be discovered.The injected-transport guarantee is unchanged
Neither addition adds a member to
MqttDeviceTransport: they only skip or perform apublish(), never add a call.test_declare_lost_and_silent_stop_stay_on_the_minimal_transport_surfaceruns both against a stub that has nostart,stoporpublish_and_flush, so an accidental owned-only call raisesAttributeErrorinstead of being silently absorbed by aMagicMock. Ownership is tested with_owns_client and _owned_client is not None, neverisinstance— issue #46's downstream bug 2 was exactly that, since an injected client can itself be a realMqttClientunderasyncio_driver.Verification
ruff format --checkclean on 0.16.1.announce=False(3), publish while disconnected (1), publish the enum member instead of.value(2).That last one was a real gap found only by mutating: on Python 3.10, which CI runs,
str(DeviceState.LOST)is"DeviceState.LOST", and==cannot see the difference becauseDeviceStateis aStrEnum. The first version of the test passed either way; it now assertstype(payload) is str.An adversarial review raised 23 findings and 22 were refuted, most by reproducing them against unmodified
HEADas pre-existing behavior. The one that survived was a real test gap: the transition-depth guard reads the root's depth deliberately (it is the root's transition exit that would publishREADYover the declaredlost), but the only test opened the transition on the same device it calleddeclare_lost()on, soselfandrootwere the same object and the scoping was never exercised — mutating it toself._transition_depthpassed the whole suite.test_warns_when_the_roots_transition_is_open_not_the_callersnow covers the child-calls-root case.Credit
Shape converged with @cayossarian in the issue thread; the
boolreturn and the injected-transport drain obligation are both their contributions, arrived at from building a natively-async transport.🤖 Generated with Claude Code