Skip to content

feat: declare_lost() and stop(announce=False) - #52

Merged
dcj merged 1 commit into
mainfrom
feat/declare-lost
Aug 13, 2026
Merged

feat: declare_lost() and stop(announce=False)#52
dcj merged 1 commit into
mainfrom
feat/declare-lost

Conversation

@dcj

@dcj dcj commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Closes #46.

Stacked on #51. Base is feat/publish-on-change-gate so the diff shows only this change. Merge #51 first; GitHub retargets this to main automatically.

Device modeled three teardowns and implemented one:

Teardown Correct $state Before
Graceful shutdown disconnected Device.stop()
Ungraceful death lost the will, which fires only on an unclean disconnect
Deliberate death lost nothing

DeviceState.LOST was published nowhere in homie.py except inside the will() descriptor, and the clean disconnect stop() performs deliberately suppresses the will. So a producer that knew it was dying could announce disconnected, which is a lie, or reach around the SDK to its client. ebus-panel-sim did the latter and shipped four bugs downstream of that one reach-around.

declare_lost() -> bool

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. There is a test asserting that equality directly, mirroring the existing test_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.md already 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 Device does not hold is precisely how a later refresh_tree() republishes ready over it — issue #46's downstream bug 4.

The return is "did $state move", reusing set_state's True-changed/False-already-there convention as suggested in the thread. On an injected transport a True from 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 stale lost long 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, since publish_and_flush is owned-only and off the MqttDeviceTransport surface. It does not stop the client.

stop(announce=False)

Tears down without publishing anything. The _state = DISCONNECTED assignment now lives inside the announcing branch, so it cannot overwrite a just-declared lost — pinned by test_announce_false_leaves_a_declared_lost_state_intact.

Named announce rather than the graceful panel-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 a publish(), never add a call. test_declare_lost_and_silent_stop_stay_on_the_minimal_transport_surface runs both against a stub that has no start, stop or publish_and_flush, so an accidental owned-only call raises AttributeError instead of being silently absorbed by a MagicMock. Ownership is tested with _owns_client and _owned_client is not None, never isinstance — issue #46's downstream bug 2 was exactly that, since an injected client can itself be a real MqttClient under asyncio_driver.

Verification

  • 596 tests pass (581 before), ruff check + ruff format --check clean on 0.16.1.
  • Mutation-tested; each fails at least one test: drop the state move (7 failures), per-device instead of root-scoped (1), ignore 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 because DeviceState is a StrEnum. The first version of the test passed either way; it now asserts type(payload) is str.

An adversarial review raised 23 findings and 22 were refuted, most by reproducing them against unmodified HEAD as 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 publish READY over the declared lost), but the only test opened the transition on the same device it called declare_lost() on, so self and root were the same object and the scoping was never exercised — mutating it to self._transition_depth passed the whole suite. test_warns_when_the_roots_transition_is_open_not_the_callers now covers the child-calls-root case.

Credit

Shape converged with @cayossarian in the issue thread; the bool return and the injected-transport drain obligation are both their contributions, arrived at from building a natively-async transport.

🤖 Generated with Claude Code

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>
@dcj
dcj force-pushed the feat/declare-lost branch from 5a444d3 to 388b31c Compare August 13, 2026 06:32
@dcj
dcj changed the base branch from feat/publish-on-change-gate to main August 13, 2026 06:32
@dcj
dcj merged commit 90758cc into main Aug 13, 2026
5 checks passed
@dcj
dcj deleted the feat/declare-lost branch August 13, 2026 06:39
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.

No way to announce death: Device models three teardowns and implements one

1 participant