Skip to content

build(deps): bump ebus-sdk from 0.19.0 to 0.20.1 - #43

Open
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/pip/ebus-sdk-0.20.1
Open

build(deps): bump ebus-sdk from 0.19.0 to 0.20.1#43
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/pip/ebus-sdk-0.20.1

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Aug 21, 2026

Copy link
Copy Markdown
Contributor

Bumps ebus-sdk from 0.19.0 to 0.20.1.

Release notes

Sourced from ebus-sdk's releases.

v0.20.1

Changed

  • CI: the pytest and ruff jobs, and the publish workflow's test gate, now carry timeout-minutes: 5. The suite runs in about two seconds, so anything near that bound is a hang rather than slowness. This matters from this release on: homie.Property now takes a lock, and a lock regression deadlocks whichever thread reaches it (making it non-reentrant deadlocks the main thread at the first set_value, which is most of the suite). Unbounded, GitHub would let that run to its six-hour default instead of reporting a failure, and a hung job reports nothing useful. The publish and release jobs are deliberately left unbounded, since a slow PyPI upload is not the same kind of event.

Fixed

  • GroupedPropertyDict's active bulk-update context is now per-thread. BulkUpdateContext.__enter__ and __exit__ mutated a shared _bulk_mode/_bulk_context pair with no lock held, while every other accessor on the class (including the observer dispatch) took its RLock, which made the omission look accidental rather than deliberate. Two threads entering bulk contexts on the same dict therefore corrupted each other: entering displaced the other's context, and whichever exited first cleared bulk mode for both. No events were lost, since __exit__ fires the context object's own list, but they were misattributed and fragmented: some of one thread's changes landed in the other's batch, and everything after the early exit fired individually instead of batching. For a Homie publisher that fragmentation is the real cost, because each structural event that escapes the batch triggers its own $description republish and a $state transition, so one logical change produces extra republishes and visible state flapping. Thread-local rather than serializing on the existing lock, since a bulk context can be held across I/O and one thread should not block for the duration of another's batch. A nested bulk_update() on the same thread now restores the enclosing context on exit instead of ending it, so the outer batch resumes rather than leaking its remainder as individual events. Two threads batching independently was always the reasonable reading; now it is the actual behavior. Reported with a precise account of which consequences do and do not follow. (#55)

  • Property now serializes "compute the payload, publish it, record what was published" under a per-property reentrant lock, so the publish-on-change memo can never disagree with the last write that actually reached the wire. Two threads reach that sequence: the application thread via set_value(), and the MQTT loop thread via on_connectrefresh_tree(force=True). Interleaved, the loop thread could publish the old payload, the application thread could then publish and memoize the new one, and the loop thread could finally overwrite the memo with the older payload it had sent first. The property then believed the broker held a value it did not, and the 0.20.0 gate suppressed the very publish that would have corrected it, so the wrong retained value persisted until the next genuine change or reconnect. The window is narrow (it needs a reconnect refresh concurrent with a value update) and the class has never had a lock, so _value and _ever_published were already exposed to it in kind; 0.20.0 made the consequence durable rather than transient, which is what moves this from a latent wart to a fix. The lock is reentrant because set_value() calls publish_value() calls clear_value(), each taking it; a plain Lock self-deadlocks on the commonest call in the SDK. It is per-property, so it never serializes a tree walk, and no path holds two, so there is no ordering hazard. It is deliberately held across the transport's publish(): releasing earlier reopens the window it exists to close. No API change. (#50)

v0.20.0

Added

  • Device.declare_lost(): a way to announce deliberate death. Device modeled three teardowns and implemented one, so a producer that knew it was failing (a fatal error handler, a supervisor about to kill it, hardware that has gone away, a simulator acting the part) could only 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. It is TREE-level like will() and stop(), publishing the ROOT's $state (per the Homie 5 effective-state rule that covers every descendant in one publish) and emitting exactly the topic and payload will() describes, so the declared and will-driven paths cannot drift; to mark a single device lost, set_state(DeviceState.LOST) on that device remains the right call, and declare_lost() would blank the whole tree's liveness. The state move and the publish happen together, and the move is unconditional, because 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 distinguishes "queued, now drain" from "already lost, nothing to wait for", 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. 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. (#46)

  • Device.stop(announce=False): tear down without publishing anything, leaving the retained $state exactly as it stands. The counterpart to declare_lost(): the default announce=True would overwrite a just-declared lost with disconnected, and the state move now lives inside the announcing branch so it cannot. Named announce rather than the graceful a downstream reached for, because "graceful" conflates the announcement with the bounded clean disconnect, and the teardown stays bounded and clean in both modes; only the announcement differs. Unpaired it leaves whatever was published last, typically a stale ready, and nothing will correct that, so the docstring and the README say so plainly. Adds nothing to the injected-transport surface: it only skips a publish, never adds a call. (#46)

  • Property.invalidate_publish_cache(): forget what a property last published, for anything that deletes its retained value topic behind its back. The publish-on-change skip below assumes the broker still holds the payload the property last sent, so an operator wiping the broker, or a call to Device.clear_retained_topic() aimed at a value topic, leaves that assumption false and the next set_value() of the same value would be skipped against an empty topic. Device.delete_all_from_mqtt() now calls it on every property it clears; clear_value() and Node.delete_property() reset the memo themselves, so only the raw-topic paths need it. Distinct from _ever_published: this says "I no longer know what the broker holds", not "I have never published". (#50)

Changed

  • Property.set_value() no longer republishes a retained value whose wire payload is byte-identical to the one it last published on that topic. Every publish previously went to the wire with no comparison against what was last sent, so a producer re-setting its property set each tick republished payloads the broker's retained store already held, at QoS 2, forever. The comparison is on the final payload, after rounding, datatype coercion and encode_empty_string(), which is why the SDK owns this rather than the caller: round_to lives inside the property, so two readings of 0.14494210481643677 and 0.14501120000000001 are genuinely different values that any caller-side change check calls "changed", and both publish 0.1. Three carve-outs, each deliberate: a non-retained (event) property is never gated, because the broker stores nothing for it and an identical consecutive payload is a second real event rather than a redundant write; retraction (set_value(None) / clear_value()) always publishes; and every whole-tree republish forces past the gate via a new keyword-only force threaded through refresh_tree(), publish_nodes(), Node.publish() and publish_value() (all defaulting to True on the walk, False on the value path). That last one is load-bearing: without it a broker restarted with an empty retained store could never be repopulated, since on reconnect every payload matches what the property "last published". The retained state left on the broker is byte-for-byte identical either way (strictly fewer messages, same truth), so the only consumers who notice are those inferring liveness from message arrival rather than from $state, which https://github.com/electrification-bus/python-sdk/blob/HEAD/doc/consuming-a-homie-tree.md has always told them not to do; it gains a fifth row and a paragraph, since this is the first producer-side suppression to touch the data plane rather than $state/$description. (#50)

  • Property.get_last_published_value() now returns the wire payload the property last published (or None), rather than the current value. It was a documented placeholder whose body was return self.value(), which made it an active trap once a real memo existed: anyone building change detection on it would have compared a value against itself, got False every time, and suppressed every publish including the first. Nothing in the SDK, its tests or its examples called it. Note the return is now the post-coercion, post-encoding string that went to the broker (an empty-string value reads back as "\x00"), not the Python value; use value() for that. (#50)

Documentation

  • README, bring-your-own-transport: a transport must preserve publish order, and why. MqttDeviceTransport says nothing about ordering because the two transports shipped with the SDK cannot violate it (paho's thread and asyncio_driver each pump one client), but a transport written against the protocol directly can, and one that starts a task per publish() hands ordering to the scheduler. The SDK maintains ordering on a producer's behalf — a device's $description precedes the $state=ready that vouches for it, and refresh_tree() publishes a device's $state after the children it announces, which is what 0.18.1 fixed — so a transport can silently drop a guarantee the SDK spends effort meeting. Consumers must still never depend on publish order (doc/consuming-a-homie-tree.md says so at length, since order does not survive retention), which is exactly why that document cannot warn a transport author: it addresses the other party. Also notes the teardown consequence: a publish() that enqueues is legitimate (every return is typed object because the SDK discards it), but Device.stop() publishes the final $state without flushing, so a queueing transport needs a drain point before the client closes. Raised by @​cayossarian from building a natively-async transport, where the hazard is real rather than theoretical. (#46)
Changelog

Sourced from ebus-sdk's changelog.

[0.20.1] — 2026-08-13

Changed

  • CI: the pytest and ruff jobs, and the publish workflow's test gate, now carry timeout-minutes: 5. The suite runs in about two seconds, so anything near that bound is a hang rather than slowness. This matters from this release on: homie.Property now takes a lock, and a lock regression deadlocks whichever thread reaches it (making it non-reentrant deadlocks the main thread at the first set_value, which is most of the suite). Unbounded, GitHub would let that run to its six-hour default instead of reporting a failure, and a hung job reports nothing useful. The publish and release jobs are deliberately left unbounded, since a slow PyPI upload is not the same kind of event.

Fixed

  • GroupedPropertyDict's active bulk-update context is now per-thread. BulkUpdateContext.__enter__ and __exit__ mutated a shared _bulk_mode/_bulk_context pair with no lock held, while every other accessor on the class (including the observer dispatch) took its RLock, which made the omission look accidental rather than deliberate. Two threads entering bulk contexts on the same dict therefore corrupted each other: entering displaced the other's context, and whichever exited first cleared bulk mode for both. No events were lost, since __exit__ fires the context object's own list, but they were misattributed and fragmented: some of one thread's changes landed in the other's batch, and everything after the early exit fired individually instead of batching. For a Homie publisher that fragmentation is the real cost, because each structural event that escapes the batch triggers its own $description republish and a $state transition, so one logical change produces extra republishes and visible state flapping. Thread-local rather than serializing on the existing lock, since a bulk context can be held across I/O and one thread should not block for the duration of another's batch. A nested bulk_update() on the same thread now restores the enclosing context on exit instead of ending it, so the outer batch resumes rather than leaking its remainder as individual events. Two threads batching independently was always the reasonable reading; now it is the actual behavior. Reported with a precise account of which consequences do and do not follow. (#55)

  • Property now serializes "compute the payload, publish it, record what was published" under a per-property reentrant lock, so the publish-on-change memo can never disagree with the last write that actually reached the wire. Two threads reach that sequence: the application thread via set_value(), and the MQTT loop thread via on_connectrefresh_tree(force=True). Interleaved, the loop thread could publish the old payload, the application thread could then publish and memoize the new one, and the loop thread could finally overwrite the memo with the older payload it had sent first. The property then believed the broker held a value it did not, and the 0.20.0 gate suppressed the very publish that would have corrected it, so the wrong retained value persisted until the next genuine change or reconnect. The window is narrow (it needs a reconnect refresh concurrent with a value update) and the class has never had a lock, so _value and _ever_published were already exposed to it in kind; 0.20.0 made the consequence durable rather than transient, which is what moves this from a latent wart to a fix. The lock is reentrant because set_value() calls publish_value() calls clear_value(), each taking it; a plain Lock self-deadlocks on the commonest call in the SDK. It is per-property, so it never serializes a tree walk, and no path holds two, so there is no ordering hazard. It is deliberately held across the transport's publish(): releasing earlier reopens the window it exists to close. No API change. (#50)

[0.20.0] — 2026-08-12

Added

  • Device.declare_lost(): a way to announce deliberate death. Device modeled three teardowns and implemented one, so a producer that knew it was failing (a fatal error handler, a supervisor about to kill it, hardware that has gone away, a simulator acting the part) could only 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. It is TREE-level like will() and stop(), publishing the ROOT's $state (per the Homie 5 effective-state rule that covers every descendant in one publish) and emitting exactly the topic and payload will() describes, so the declared and will-driven paths cannot drift; to mark a single device lost, set_state(DeviceState.LOST) on that device remains the right call, and declare_lost() would blank the whole tree's liveness. The state move and the publish happen together, and the move is unconditional, because 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 distinguishes "queued, now drain" from "already lost, nothing to wait for", 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. 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. (#46)

  • Device.stop(announce=False): tear down without publishing anything, leaving the retained $state exactly as it stands. The counterpart to declare_lost(): the default announce=True would overwrite a just-declared lost with disconnected, and the state move now lives inside the announcing branch so it cannot. Named announce rather than the graceful a downstream reached for, because "graceful" conflates the announcement with the bounded clean disconnect, and the teardown stays bounded and clean in both modes; only the announcement differs. Unpaired it leaves whatever was published last, typically a stale ready, and nothing will correct that, so the docstring and the README say so plainly. Adds nothing to the injected-transport surface: it only skips a publish, never adds a call. (#46)

  • Property.invalidate_publish_cache(): forget what a property last published, for anything that deletes its retained value topic behind its back. The publish-on-change skip below assumes the broker still holds the payload the property last sent, so an operator wiping the broker, or a call to Device.clear_retained_topic() aimed at a value topic, leaves that assumption false and the next set_value() of the same value would be skipped against an empty topic. Device.delete_all_from_mqtt() now calls it on every property it clears; clear_value() and Node.delete_property() reset the memo themselves, so only the raw-topic paths need it. Distinct from _ever_published: this says "I no longer know what the broker holds", not "I have never published". (#50)

Changed

  • Property.set_value() no longer republishes a retained value whose wire payload is byte-identical to the one it last published on that topic. Every publish previously went to the wire with no comparison against what was last sent, so a producer re-setting its property set each tick republished payloads the broker's retained store already held, at QoS 2, forever. The comparison is on the final payload, after rounding, datatype coercion and encode_empty_string(), which is why the SDK owns this rather than the caller: round_to lives inside the property, so two readings of 0.14494210481643677 and 0.14501120000000001 are genuinely different values that any caller-side change check calls "changed", and both publish 0.1. Three carve-outs, each deliberate: a non-retained (event) property is never gated, because the broker stores nothing for it and an identical consecutive payload is a second real event rather than a redundant write; retraction (set_value(None) / clear_value()) always publishes; and every whole-tree republish forces past the gate via a new keyword-only force threaded through refresh_tree(), publish_nodes(), Node.publish() and publish_value() (all defaulting to True on the walk, False on the value path). That last one is load-bearing: without it a broker restarted with an empty retained store could never be repopulated, since on reconnect every payload matches what the property "last published". The retained state left on the broker is byte-for-byte identical either way (strictly fewer messages, same truth), so the only consumers who notice are those inferring liveness from message arrival rather than from $state, which https://github.com/electrification-bus/python-sdk/blob/main/doc/consuming-a-homie-tree.md has always told them not to do; it gains a fifth row and a paragraph, since this is the first producer-side suppression to touch the data plane rather than $state/$description. (#50)

  • Property.get_last_published_value() now returns the wire payload the property last published (or None), rather than the current value. It was a documented placeholder whose body was return self.value(), which made it an active trap once a real memo existed: anyone building change detection on it would have compared a value against itself, got False every time, and suppressed every publish including the first. Nothing in the SDK, its tests or its examples called it. Note the return is now the post-coercion, post-encoding string that went to the broker (an empty-string value reads back as "\x00"), not the Python value; use value() for that. (#50)

Documentation

  • README, bring-your-own-transport: a transport must preserve publish order, and why. MqttDeviceTransport says nothing about ordering because the two transports shipped with the SDK cannot violate it (paho's thread and asyncio_driver each pump one client), but a transport written against the protocol directly can, and one that starts a task per publish() hands ordering to the scheduler. The SDK maintains ordering on a producer's behalf — a device's $description precedes the $state=ready that vouches for it, and refresh_tree() publishes a device's $state after the children it announces, which is what 0.18.1 fixed — so a transport can silently drop a guarantee the SDK spends effort meeting. Consumers must still never depend on publish order (doc/consuming-a-homie-tree.md says so at length, since order does not survive retention), which is exactly why that document cannot warn a transport author: it addresses the other party. Also notes the teardown consequence: a publish() that enqueues is legitimate (every return is typed object because the SDK discards it), but Device.stop() publishes the final $state without flushing, so a queueing transport needs a drain point before the client closes. Raised by @​cayossarian from building a natively-async transport, where the hazard is real rather than theoretical. (#46)
Commits
  • 3a863e3 fix: two thread-safety fixes (0.20.1) - property publish lock, per-thread bul...
  • 67300e0 release: 0.20.0 (publish-on-change gate + declare_lost teardown) (#54)
  • 23bc64f docs: correct four stale claims and two markdownlint errors (#53)
  • 90758cc feat: declare_lost() and stop(announce=False) (#52)
  • c8562df feat: do not republish a property value whose payload is unchanged (#51)
  • 049e876 docs: a transport must preserve publish order, and why (#48)
  • See full diff in compare view

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

Bumps [ebus-sdk](https://github.com/electrification-bus/python-sdk) from 0.19.0 to 0.20.1.
- [Release notes](https://github.com/electrification-bus/python-sdk/releases)
- [Changelog](https://github.com/electrification-bus/python-sdk/blob/main/CHANGELOG.md)
- [Commits](electrification-bus/python-sdk@v0.19.0...v0.20.1)

---
updated-dependencies:
- dependency-name: ebus-sdk
  dependency-version: 0.20.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file python Pull requests that update python code labels Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants