Skip to content

Releases: electrification-bus/python-sdk

v0.24.0

Choose a tag to compare

@github-actions github-actions released this 04 Oct 06:02
7c99e48

Fixed

  • homie.Device.as_dict() no longer raises TypeError: unhashable type: 'dict' for a device with a node: it built each node entry as a set literal, {node_id, node.as_dict()}, instead of a dict entry (#87).

Changed

  • ebus-mqtt-client floor raised to 0.6.0. It holds every publish issued while the link is down and flushes it before the SDK's on-connect republish; 0.5.0 left QoS 1 and 2 publishes to paho, which replayed them after that republish and past mosquitto's 20-message receive quota, and mosquitto discarded the excess QoS 2 messages. A root built with mqtt_cfg= publishes during construction, before CONNACK, so simple-device and simple-tree-device ended with the root's retained $state on init instead of ready (electrification-bus/ebus-mqtt-client#20).

v0.23.1

Choose a tag to compare

@github-actions github-actions released this 21 Aug 15:04
7b08e07

Fixed

  • DeviceTreeBuilder.resolve_deferred() no longer spins forever when the device a deferred spec was waiting for has already been built by an equal-but-distinct DeviceSpec. 0.23.0 moved add()'s bookkeeping to the resolved device id and did not move the deferred paths with it: add()'s id short-circuit returned the existing device before reaching the identity-based self._deferred.remove(spec), so the spec stayed queued while resolve_deferred() counted the returned device as progress and looped over an unchanged queue. This is a regression of exactly the pattern the id-keying was introduced to enable, which is what made it reachable by following the new guidance. Two changes, because one of them would have been enough and the other makes the class of bug degrade instead of hang: the queue drains on the short-circuit path as well as the full-materialization path, keyed by resolved id rather than object identity; and resolve_deferred()'s progress is now the queue actually shrinking, never add() returning something, so a future path that answers without draining is a no-op rather than a spin. (#82)

  • DeviceTreeBuilder latches the id a spec resolved to when it was built, so a resolver that stops answering cannot orphan its device. A producer's device_id callable commonly reads the producer's own model, and that model can stop answering exactly when teardown begins; remove() re-resolved through the callable, got None, and returned silently, leaving the device live on the broker with its retained topics. remove(), device_for(), homie_properties() and extend() now consult the latch first and fall back to resolving afresh, so a stale resolver is safe and an equal-but-distinct spec still works. The latch is dropped with the device it named, so a rebuilt spec resolves again rather than answering from a dead entry.

Changed

  • A PropertySpec whose python type disagrees with a property already in the model is reused rather than refused. The check added in 0.23.0 guarded a difference with no runtime consequence: the observable property's type is metadata, nothing reads it, set_value neither coerces nor validates against it, and wire coercion belongs to the Homie property. It also misfired on the normal case, a producer whose model uses a richer python type than the datatype-derived default (an Enum subclass for an ENUM property, which in one real declaration set is 30 of 134 definitions), and it raised MID materialization, leaving a half-built device behind rather than failing at declaration time. Logged at debug instead.

v0.23.0

Choose a tag to compare

@github-actions github-actions released this 21 Aug 02:33
f2c5f5f

Added

  • DeviceTreeBuilder.remove_capabilities(): the inverse of extend(). A capability that becomes relevant at runtime can stop being relevant, and without this its node stayed advertised in $description with retained topics behind it. Device.delete_node() already clears those and re-announces, so what this closes is the bookkeeping: reaching around the builder to call it left model_keys and created_groups describing properties that no longer existed, and a later remove() working from that stale record. Idempotent like extend(), and named for capabilities rather than nodes because that is the declarative vocabulary. (#78)

Changed

  • DeviceTreeBuilder keys its bookkeeping on the resolved device id rather than on DeviceSpec object identity. A producer deriving its spec set from a manifest re-derives equal-but-distinct objects on every pass, and identity keying made each pass a new device; the alternative was an unstated obligation to hold a device_id -> DeviceSpec map for the process lifetime and never re-derive, which is precisely what a declarative API exists to avoid. add(), remove(), extend(), device_for() and homie_properties() now all answer for any spec naming the same device. add() remains idempotent on the DEVICE rather than on the declaration: a differing capability set on an already-built id returns the existing device unchanged rather than applying the difference, since add() mutating a live tree is not what its name suggests; extend() is how a built device grows. Deferred specs stay keyed by identity, having no id yet by definition. (#74)

Fixed

  • conditionally_settable was inert: _materialize never read it. The half that looked right is that the property did come out not-settable; the half that bit is that the entity_setter was registered only when settable was true, so the caller's later set_settable(True) opened a /set topic with no translator behind it. The property then advertised that it accepts commands and silently discarded them, which is the exact failure the field was introduced to avoid, one step further along. It is the only route the API offers for per-instance settability decided at runtime, and it was the route that did not work. The translator is now wired at build time even though the property starts not-settable. The test that shipped with the feature asserted only the not-settable half, which is why this survived review: a test written from the design rationale checks the rationale rather than the feature. (#72)

  • A value the model already held is now published when its Homie twin is built. The binding is on-change and a fresh twin starts empty, so a producer whose model predates the tree (the arrangement DeviceTreeBuilder documents as its reason for accepting an external model) announced its declared default instead of the live value, on every property. It did not self-heal, because set_value fires callbacks only on an actual change, so a value written once at group creation stayed wrong for the process lifetime. This became reachable in 0.22.0: before the reuse fix the model property was replaced, so twin and model started equally empty. Relatedly, a declared initial_value now SEEDS rather than overwrites, since a model already holding a value holds a fresher one than the declaration; an explicit values entry still wins, being a statement about this run. (#77)

  • DeviceTreeBuilder.remove() no longer raises when the producer's model has already dropped the group. GroupedPropertyDict.delete_group removes the group BEFORE firing GROUP_DELETED and dispatch is synchronous, so a consumer driving remove() from that event was guaranteed to hit it. It raised after device.delete() and before the bookkeeping pop, leaving the device gone from the broker while the builder still held a corpse that short-circuited the next add(): unrecoverable for the process lifetime, and inside an observer callback it surfaced as a single swallowed warning. Bookkeeping is now dropped in a finally, and the per-property deletePropertyGroupNotFound warning burst is gone with it, which matters on bounded-disk fleet devices. (#73)

  • DeviceTreeBuilder.remove() now prunes deferred descendants, not only the removed spec. A deferred child holds a frozen reference to its parent spec, so resolve_deferred() would rebuild a device that had been deliberately torn down. The shape most likely to hit it is a mandatory child deferred on a late identifier, which sits in the queue for exactly the window in which its parent might be removed. (#75)

  • DeviceTreeBuilder.add() records its bookkeeping before materializing rather than after. The device is constructed, attached and broker-visible by then, so a raise left a live device the builder had no record of: device_for() returned None, remove() was a silent no-op, and the retained topics were stranded. The same window admitted re-entry, since the model's events dispatch synchronously and a producer observing its own model could call add() again before the cache entry existed. (#76)

v0.22.0

Choose a tag to compare

@github-actions github-actions released this 21 Aug 01:18
4bb4577

Added

  • DeviceTreeBuilder.add_root_capabilities(): the tree's root can carry its own capabilities. add() only ever creates children, so a root's own surfaces (an enclosure's aggregate metering, its state, its controls) had no declarative expression and had to be hand-rolled beside the builder, which left one model with two construction styles and put the root outside every guarantee the builder gives (idempotence, ordered teardown, model cleanup). The root already exists, so this materializes onto it rather than constructing anything; the model group defaults to the root's device id, matching how add() keys a child. root_capabilities() reads back what has accumulated. (#67)

  • DeviceTreeBuilder.extend(): a device that already exists can grow a capability. A capability set is not always known when a device is first published, since a storage system commissioned at runtime gives an enclosure shed and forecast surfaces it did not have at boot, and add() short-circuits an already-built spec, so the builder modeled devices appearing and disappearing but not a device growing. The workaround was unsafe rather than merely absent: Device.add_node and Node.add_property both replace wholesale, so re-declaring a live device through build_from_declarations dropped the previous node's properties from $description while leaving their retained topics on the broker, and only delete_node clears those. The result was a tree whose description and whose broker state disagreed, persisting across restarts. extend() materializes inside one state_transition() and folds the new model keys into the same bookkeeping remove() uses. (#68)

  • node_id on build_from_declarations and DeviceTreeBuilder: a callable mapping a capability to the Homie node id it materializes onto, defaulting to the capability itself. The node id was hardcoded to the capability name, which is right until one device carries two instances of the same capability (two lugs, two meters), at which point the second silently lands on the first one's node. node_type and node_name were already callables, so the id was the one part of a node a caller could not choose. Renaming is all it does: the declaration's vocabulary stays capability, the model group still comes from the spec, and the returned map is still keyed by the declared capability, so a caller who ignores it sees no change. Pairs with PropertySpec.model_group from 0.21.0, which separates the same two instances in the model the way this separates them on the wire; using one without the other moves the collision rather than removing it. (#47)

Changed

  • Materializing declarations is now idempotent, at three levels. An existing Homie node is reused rather than replaced (Device.add_node is a wholesale self._nodes.update(...), which is the mechanism behind the description-versus-broker divergence above); an existing Homie property is reused rather than re-added (Node.add_property replaces and republishes with force=True); and a materialization that would create nothing does not open a state transition at all. That last one matters most: an empty transition still emits init then ready, and that edge forces every controller on the bus to resync, so a re-declaration that changes nothing must not cost one. Together these make build_from_declarations safe to call twice, which is what lets a re-fired incremental lifecycle be a genuine no-op rather than a quieter republish.

Fixed

  • build_from_declarations and DeviceTreeBuilder now REUSE an observable property the model already holds instead of replacing it. _materialize guarded the model group with has_group and then, two lines later, added the property unconditionally, and GroupedPropertyDict.add_property is a wholesale self._properties[property_id] = property. So a producer handing over a model it had already populated got that property swapped for a fresh one, losing its value and, worse, every callback and entity_setter attached to it: $description kept advertising settable: true while the actuator behind it was gone, and an arriving /set did nothing. Nor was it self-healing, since the builder path seeds only a static initial_value and Property.set_value fires callbacks only on an actual change, so a value written once at group creation never republished. This is the exact case DeviceTreeBuilder documents as the reason it accepts a model rather than creating one, which made the gap a documented guarantee the code did not provide. The builder now records only properties it actually created, so removing a device deletes what it added and leaves what the producer owned. A spec whose python type disagrees with the property already in the model raises rather than binding a Homie twin to a mismatched observable. (#66)

  • Device refuses a child whose id collides with any ancestor's. Device.__init__ appended to parent._children with no check, so a child carrying the root's id made the root name itself in its own children and put two devices on the same topics, with no exception and no warning. The obvious way to reach it was trying to express "capabilities on the root" as a DeviceSpec with parent=None and the root's own id, which is a real thing to want and which the builder does not yet support; failing loudly beats materializing a malformed tree. Ids still only have to be unique within a tree's ancestry, so the same id under a different root is unaffected. The same defect one step sideways is refused too: two children of one parent sharing an id derived the same base topic, so their $description publishes overwrote each other on the broker while the parent named that child twice in its own children list. delete() detaches a child, so recreating one after deleting it is not affected. (#67)

Documentation

  • DeviceTreeBuilder now states two parts of its contract that the API alone did not convey, both reported by a consumer reconciling an existing multi-device builder against it. First, whether a producer is expected to adapt its own model to GroupedPropertyDict or to own one: it is the second, which is the observable-model pattern the proxy guide prescribes, and the builder accepts rather than creates one so a single model can span a tree and so a producer holding one already can hand it over. Second, the limit of add()'s ordering: it orders late-bound ids and builds an unbuilt parent it was handed, but DeviceSpec is frozen and parent is a direct reference, so a child spec cannot be constructed before its parent spec exists. A caller deriving specs from a source that names parents indirectly still owns that dependency ordering. add() reads as though ordering is handled generally; it is handled for ids. Thanks to @cayossarian (#49).

v0.21.0

Choose a tag to compare

@github-actions github-actions released this 20 Aug 15:38
bbd1c5c

Added

  • Device(homie_domain=...): a tree can publish under any Homie 5 domain, not only ebus. The consumer side was already configurable (Controller(homie_domain=...) and DiscoveredDevice both take one, and _on_state_message even parses the domain out of the topic), while the publisher side hardcoded the EBUS_HOMIE_DOMAIN constant at ten topic-construction sites across Device, Node and Property, plus the Last Will, so the SDK could consume any Homie 5 tree and produce only an eBus one. The default is unchanged and eBus energy devices keep publishing under ebus, which the specification mandates; what this buys is that the same SDK can also publish non-energy devices under the standard homie domain, which is the difference between an eBus library and a Homie 5 library that defaults to eBus. The domain covers everything a tree derives: property values, /set subscriptions, $state, $description, the retraction topics delete() clears, and the will. Inbound /set validation follows too: the topic check accepted only ebus and now accepts the tree's own domain, so a device under homie can actually be commanded. It is a property of the TREE rather than of a device, so only a root carries it, descendants read it through the new Device.homie_domain(), and a child passing its own is refused with a ValueError exactly as a child passing its own mqtt_cfg is; refused even when the value would have matched, because the rule is structural rather than a value check. The Device docstring's "homie_domains config for future use, not currently supported by this code" stub is replaced by what to actually do. (#61)

  • DeviceSpec and DeviceTreeBuilder: a device-level declaration and a tree-aware, incremental builder, for publishers whose shape is a tree rather than one device. build_from_declarations materializes exactly one device and creates the observable model itself keyed by capability, which fits the single-device proxy the SDK was first written for and cannot express what the eBus framework actually describes: a root whose circuits, lugs, MID and DERs are child devices, each with its own id, $state, $description and capability set. Three independent consumers had hand-rolled the same layer on top of homie.Device(parent=...), which is evidence about the SDK rather than about them. Device class, id and parent are device-level facts, so they live on DeviceSpec rather than being repeated on every property of the device; device_type defaults to energy.ebus.device.{device_class}, which matters more than a convenience default because the SDK stores Device.type verbatim and validates nothing against a registry, so the derived form is the main guard against a type that ships misspelled. The builder accepts a GroupedPropertyDict it does not own and keys each device's group by device rather than capability, since two children both exposing info otherwise collide in the model while remaining perfectly distinct on the wire; a PropertySpec naming its own model_group still wins, so a consumer with an existing model keeps its keying. Late-bound ids are first class: device_id may be a callable returning None while an asynchronous identifier has not arrived, add() returns None and remembers the spec, and resolve_deferred() resolves a whole generation including children waiting behind a deferred parent. That is worth the machinery because a child published under a wrong-but-stable id leaves retained topics that outlive restarts and firmware updates. add() is idempotent because incremental lifecycles re-fire; remove() is depth-first, grandchild before parent, derived from the live tree rather than a caller-maintained ordering, so nothing ever observes an orphaned child, and it also deletes the model entries the builder added plus any group it created that is now empty. on_created carries per-child side effects so consumers do not post-process the returned tree. (#57)

  • PropertySpec reaches property-level parity with the private declaration types that multi-device publishers were keeping instead of using it. Seven new fields, each defaulting to what the spec did before it existed, so no existing declaration set changes: round_to (decimal places applied on publish, which the property already supported and the declaration could not reach); initial_value (a seed applied through the model at build, overridden by the builder's values= argument); retained=False (an event property rather than a state); internal_only (the model tracks the value and the wire never sees it, so no Homie property is created and a capability whose specs are all internal gets no node); conditionally_settable (settability decided per instance at runtime, materialized not-settable so $description stays honest and no /set topic is opened on a property that would reject the command); and source_id / model_group, which split the observable-model identity from the wire identity. That last split is the load-bearing one: capability was simultaneously the Homie node id and the model group key, which is the same string only while one device is in play, and two child devices in a tree that both expose info collide in a shared model while remaining perfectly distinct on the wire. Two contradictions are now refused when the spec is constructed rather than when it publishes: settable with conditionally_settable, and internal_only with either. (#58)

Fixed

  • bind_property_to_homie now binds a non-retained property on-set rather than on-change, so an event property can actually emit repeated events. The observable model fires on-change callbacks only when the value differs, and that gate sits above the Homie layer, so the publish-on-change exemption 0.20.0 gave non-retained properties was unreachable through the SDK's own recommended path: two identical consecutive events were swallowed by the model before the Homie property ever saw the second one. For a retained property nothing changes, and the model gate remains the cheap first line of defense; for an event property the repeat is the point, since the broker stores nothing and a subscriber learns of the event only by receiving it. A twin that does not answer retained() is treated as retained, which is what every twin got before the distinction existed. Found while testing the new retained field, which would otherwise have shipped as a declaration that looks like it enables event semantics and does not. (#58)

Documentation

  • PropertySpec.scale's docstring said the value is "metadata for a caller's mapping/resolver and is NOT applied by the builder", which is half the story and the half that misleads: resolve() does apply it, and specs_and_values() hands build_from_declarations values that have already been scaled, which is exactly why the builder must not scale them again. Stated positively in both docstrings and in doc/building-a-proxy.md, with a test that pins it, so the next reader of either call site learns the rule from the one they happen to open. A caller assembling a values map by hand passes values in the property's own unit.

  • doc/building-a-proxy.md gains a "Beyond the basic fields" section: one row per PropertySpec field beyond the common five, written as "use it when" rather than "it means", since the fields are individually obvious and it is knowing which problem each solves that is not.

v0.20.1

Choose a tag to compare

@github-actions github-actions released this 13 Aug 07:37
3a863e3

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_connect → refresh_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

Choose a tag to compare

@github-actions github-actions released this 13 Aug 06:38
67300e0

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 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)

v0.19.0

Choose a tag to compare

@github-actions github-actions released this 07 Aug 14:42
31f8376

Added

  • Controller.is_tree_complete(root_id) and Controller.set_on_tree_ready_callback(): a first-class answer to "has the declared tree fully described itself?". $state=ready is a per-device signal meaning "my own $description is current" and never promised anything about descendants, so a consumer needing a whole-tree gate previously had to hand-roll one, and hand-rolling it is how you end up with a one-shot barrier that stops reconciling and silently misses a device commissioned later. Both are built as reconciling predicates instead: is_tree_complete() walks the declared tree on demand and flips back to False when a device declares a new child, and the callback is edge-triggered but re-arms, firing again for each settled shape. Completeness is about description, not liveness: a declared child that is lost has still told you what it is, so use get_effective_state() for liveness. A declared cycle terminates rather than hanging. (#37)

Changed

  • Tooling: the ruff lint selection is now declared explicitly (select = ["E4", "E7", "E9", "F"]) rather than inherited from ruff's defaults, and CI moves from ruff 0.15.21 to 0.16.1. Ruff 0.16 widened its default selection (UP, LOG, BLE, I, RUF and more) and began formatting Python code blocks embedded in Markdown, so an unchanged codebase reported 0 or 381 violations depending only on which ruff you happened to run, and four docs files showed phantom format diffs locally that CI never saw. Pinning the set decouples "what this project lints for" from "what version of ruff is installed"; extend-exclude = ["*.md"] keeps prose out of both check and format. Verified clean on both 0.16.1 and 0.15.21, with no source changes. Widening the rule set (the 381) is now a deliberate act rather than an upgrade side-effect. (#39)

  • Device.refresh_tree() is now explicitly best-effort: a descendant whose republish raises is logged (reason=deviceRefreshTreeChildFailed) and skipped, and the cascade continues. Property.publish_value() reaches the MQTT client without wrapping it and Node.publish() had no guard, so a bring-your-own-transport client that raised on one property aborted the walk from wherever it failed, taking out every later sibling, every ancestor's $state, and therefore the whole tree's reconnect. One sick device could keep an entire enclosure off the broker. Node.publish() now contains a raising property at the same grain. The policy is stated in the refresh_tree() docstring rather than left implicit; the exception is not re-raised. (#36)

  • Device.remove_node() and Device.delete_node() now cross-reference each other in their docstrings. They sit adjacent and differ in one consequential way (remove_node() drops the node from the schema but leaves its properties' retained value topics on the broker; delete_node() clears them too), and neither said so, which made picking the wrong one a silent way to strand retained topics. Docs only, no behaviour change. (#38)

Fixed

  • Node.delete_property() now republishes $description, as its mirror Node.add_property() always has. Deleting a property cleared the retained value topic but left the device in ready with a $description that still named the property, and nothing corrected it afterwards, so the broker held a self-contradicting device indefinitely. The two halves of the same API disagreed about whether mutating a node's property set is a structural change; it is. Deletions batch inside device.state_transition() exactly as additions do, so N deletions still collapse to one $description publish. (#35)

v0.18.1

Choose a tag to compare

@github-actions github-actions released this 07 Aug 14:14
19eef09

Added

  • doc/consuming-a-homie-tree.md: the controller/subscriber-side guide, and the counterpart to doc/building-a-proxy.md. It states the asymmetry the convention relies on and that this repo had never written down: a producer SHOULD minimize $state and $description transitions (quality-of-implementation, best-effort), while a consumer MUST react to every one of them, unconditionally (correctness). Covers what $state=ready does and does not promise (it means "my own $description is current", never "my children are present", and no producer can make the latter true since children are commissioned out of band), and the three ways consumers get this wrong: the one-shot barrier that stops reconciling after startup, awaiting a $description that the content-hash suppression never sends, and inferring publish order across retained messages. Linked from the README.

Fixed

  • Device.refresh_tree() published a device's own $state before recursing to its children, so a device announced ready while the children its $description names had published nothing. on_connect calls refresh_tree() for SDK-owned clients on both the initial connect and every reconnect, so this was the normal path rather than an edge case. Description and nodes are now published first, then descendants, then this device's own state. That matches the add-child path (where a child publishes itself fully before its parent re-announces), and it makes a reconnect one atomic commit: after an ungraceful drop the LWT leaves the root retained as lost, Homie 5 makes every child of a lost root lost too, so a single final publish flips the whole tree at once. The set of messages is unchanged; only their order is (verified on a 37-device tree: 74 publishes and 74 unique topics before and after, identical topic sets). Note this narrows a producer-side window and is not a guarantee consumers may build on: $state=ready still means "my own $description is current", never "my children are present". Thanks to @cayossarian. (#31)

v0.18.0

Choose a tag to compare

@github-actions github-actions released this 05 Aug 22:41
99cd3be

Fixed

  • ebus_default_override: the customizer's state-of-charge entry was keyed battery, which is not an eBus capability and never has been. State of charge lives in energy.ebus.capability.soc, so a conformant device typing its node that way resolved to the key soc, missed the table, and reached Home Assistant with neither a device_class nor a state_class on its SoC: precisely the ambiguous bare percent the entry exists to resolve. The entry is re-keyed soc and its property ids corrected to the ones the capability actually defines. state-of-charge, power and temperature are dropped: none is a property of any eBus capability, a battery's electrical power is meter/active-power (already covered), and eBus does not model pack temperature. Note this removes the battery key rather than aliasing it, so a publisher that copied the old example's non-conformant energy.ebus.capability.battery node type should move to soc. Thanks to @cayossarian. (#27)
  • soc's energy properties are no longer emitted as accumulating registers. soe, total-energy-storage and loadup-headroom are reservoir levels that FALL on discharge, but unit inference sees Wh/kWh and says energy + total_increasing, under which Home Assistant reads every discharge as a meter reset and back-fills the drop as freshly consumed energy, corrupting the Energy dashboard's long-term statistics. They now carry HA's energy_storage device class with state_class: measurement, its level counterpart (same units, min/max/mean rather than a sum). info/nameplate-capacity gets the same correction, being a constant rather than a register. (#27)
  • Two meter entries, imported-active-energy and exported-active-energy, named properties the meter capability has never defined at any version; they are removed. They were behaviorally inert (a Wh property already infers energy + total_increasing), but they were table-versus-catalog drift of the same kind as the battery key. The four cumulative reactive/apparent registers (imported-reactive-energy, exported-reactive-energy, apparent-energy-imported, apparent-energy-exported) now carry state_class: total_increasing; Home Assistant has no energy device_class for varh/VAh, so they deliberately still emit without one, but the missing state class meant HA kept no long-term statistics for them at all. The per-phase power-factor-{a,b,c} forms are added alongside the system-level power-factor already present, being likewise unitless and so invisible to inference. (#27)

Changed

  • Dependency floor: ebus-mqtt-client>=0.4.0 (was >=0.3.0), and the bring-your-own-transport section of the README now documents the loop-native path it adds. The injection seam answers WHICH connection a producer publishes through, but paho's network loop still has to be pumped somewhere, and by default that is a background thread: the exact thing the seam's motivating host (Home Assistant) forbids. 0.4.0's MqttClient.asyncio_driver() pumps that loop on the caller's asyncio loop instead, so the README can now point at an answer where it previously only named the problem. The SDK does not import the driver and needs nothing from 0.4.0 at runtime (the full test suite passes unchanged against it); the floor rises because a consumer following the documented guidance needs it present. The driver module loads lazily and imports only the standard library plus paho, so a thread-mode consumer or a constrained build never loads it.
  • .ebus-spec.json re-synced against the specification (HEAD 922b9f8), which it had drifted 49 commits behind: framework 0.5 -> 0.7, capabilities/info 0.1 -> 0.2, capabilities/meter 0.1 -> 0.2, devices/utility-meter 0.3 -> 0.6, registries/capability-types 0.11 -> 0.19, registries/device-types 0.4 -> 0.5. The supports list is unchanged: no framework feature was added or removed across 0.5 -> 0.7. Two substantive corrections beyond the version numbers: grid, status, demand and power-quality are now pinned directly, because utility-meter 0.6 split them out of the utility-meter data model into standalone catalogs and the old notes rationale ("covered by pinning utility-meter 0.3") therefore no longer held; and soc 0.1 is pinned, the customizer having first-class knowledge of it. None of the version bumps required SDK code changes: each is additive-MAY or prose. device-types 0.5 is upstream catching up to its own data models: auditing this repo against the catalogs turned up four device-type identifiers the specification's data models declare but its registry had never listed, including energy.ebus.device.utility-meter, the type this repo's reference publisher emits. Reported by @cayossarian, who ran the specification's drift-report.py against us. (#27)

Added

  • tests/test_catalog_drift.py: a regression harness for the class of defect above, rather than just its instance. The customizer table restates specification facts (capability names, property ids) and nothing checked that it still matched, which is why an invented key survived: the SDK's own tests asserted the table against itself by constructing the node type the table named. The check walks _CAPABILITY_META against the machine-readable capabilities/*.json catalogs in a sibling specification checkout and reports every mismatched name at once. It reads the specification's HEAD rather than the synced_commit pinned in .ebus-spec.json (the catalogs postdate that pin), and it SKIPS cleanly when no checkout is present, which is the case in CI today; EBUS_SPEC_DIR points it elsewhere. Proposed by @cayossarian. (#27)