Repository navigation
Releases: electrification-bus/python-sdk
Release list
v0.24.0
Fixed
homie.Device.as_dict()no longer raisesTypeError: 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-clientfloor 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 withmqtt_cfg=publishes during construction, before CONNACK, sosimple-deviceandsimple-tree-deviceended with the root's retained$stateoninitinstead ofready(electrification-bus/ebus-mqtt-client#20).
v0.23.1
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-distinctDeviceSpec. 0.23.0 movedadd()'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-basedself._deferred.remove(spec), so the spec stayed queued whileresolve_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; andresolve_deferred()'s progress is now the queue actually shrinking, neveradd()returning something, so a future path that answers without draining is a no-op rather than a spin. (#82) -
DeviceTreeBuilderlatches the id a spec resolved to when it was built, so a resolver that stops answering cannot orphan its device. A producer'sdevice_idcallable commonly reads the producer's own model, and that model can stop answering exactly when teardown begins;remove()re-resolved through the callable, gotNone, and returned silently, leaving the device live on the broker with its retained topics.remove(),device_for(),homie_properties()andextend()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
PropertySpecwhose 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'stypeis metadata, nothing reads it,set_valueneither 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 (anEnumsubclass for anENUMproperty, 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
Added
DeviceTreeBuilder.remove_capabilities(): the inverse ofextend(). A capability that becomes relevant at runtime can stop being relevant, and without this its node stayed advertised in$descriptionwith 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 leftmodel_keysandcreated_groupsdescribing properties that no longer existed, and a laterremove()working from that stale record. Idempotent likeextend(), and named for capabilities rather than nodes because that is the declarative vocabulary. (#78)
Changed
DeviceTreeBuilderkeys its bookkeeping on the resolved device id rather than onDeviceSpecobject 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 adevice_id -> DeviceSpecmap for the process lifetime and never re-derive, which is precisely what a declarative API exists to avoid.add(),remove(),extend(),device_for()andhomie_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, sinceadd()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_settablewas inert:_materializenever read it. The half that looked right is that the property did come out not-settable; the half that bit is that theentity_setterwas registered only whensettablewas true, so the caller's laterset_settable(True)opened a/settopic 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
DeviceTreeBuilderdocuments 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, becauseset_valuefires 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 declaredinitial_valuenow SEEDS rather than overwrites, since a model already holding a value holds a fresher one than the declaration; an explicitvaluesentry 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_groupremoves the group BEFORE firingGROUP_DELETEDand dispatch is synchronous, so a consumer drivingremove()from that event was guaranteed to hit it. It raised afterdevice.delete()and before the bookkeeping pop, leaving the device gone from the broker while the builder still held a corpse that short-circuited the nextadd(): unrecoverable for the process lifetime, and inside an observer callback it surfaced as a single swallowed warning. Bookkeeping is now dropped in afinally, and the per-propertydeletePropertyGroupNotFoundwarning 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, soresolve_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 calladd()again before the cache entry existed. (#76)
v0.22.0
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 howadd()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, andadd()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_nodeandNode.add_propertyboth replace wholesale, so re-declaring a live device throughbuild_from_declarationsdropped the previous node's properties from$descriptionwhile leaving their retained topics on the broker, and onlydelete_nodeclears those. The result was a tree whose description and whose broker state disagreed, persisting across restarts.extend()materializes inside onestate_transition()and folds the new model keys into the same bookkeepingremove()uses. (#68) -
node_idonbuild_from_declarationsandDeviceTreeBuilder: 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_typeandnode_namewere 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 stayscapability, 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 withPropertySpec.model_groupfrom 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_nodeis a wholesaleself._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_propertyreplaces and republishes withforce=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 emitsinitthenready, and that edge forces every controller on the bus to resync, so a re-declaration that changes nothing must not cost one. Together these makebuild_from_declarationssafe 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_declarationsandDeviceTreeBuildernow REUSE an observable property the model already holds instead of replacing it._materializeguarded the model group withhas_groupand then, two lines later, added the property unconditionally, andGroupedPropertyDict.add_propertyis a wholesaleself._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 andentity_setterattached to it:$descriptionkept advertisingsettable: truewhile the actuator behind it was gone, and an arriving/setdid nothing. Nor was it self-healing, since the builder path seeds only a staticinitial_valueandProperty.set_valuefires callbacks only on an actual change, so a value written once at group creation never republished. This is the exact caseDeviceTreeBuilderdocuments 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) -
Devicerefuses a child whose id collides with any ancestor's.Device.__init__appended toparent._childrenwith no check, so a child carrying the root's id made the root name itself in its ownchildrenand 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 aDeviceSpecwithparent=Noneand 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$descriptionpublishes overwrote each other on the broker while the parent named that child twice in its ownchildrenlist.delete()detaches a child, so recreating one after deleting it is not affected. (#67)
Documentation
DeviceTreeBuildernow 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 toGroupedPropertyDictor 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 ofadd()'s ordering: it orders late-bound ids and builds an unbuilt parent it was handed, butDeviceSpecis frozen andparentis 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
Added
-
Device(homie_domain=...): a tree can publish under any Homie 5 domain, not onlyebus. The consumer side was already configurable (Controller(homie_domain=...)andDiscoveredDeviceboth take one, and_on_state_messageeven parses the domain out of the topic), while the publisher side hardcoded theEBUS_HOMIE_DOMAINconstant at ten topic-construction sites acrossDevice,NodeandProperty, 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 underebus, which the specification mandates; what this buys is that the same SDK can also publish non-energy devices under the standardhomiedomain, 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,/setsubscriptions,$state,$description, the retraction topicsdelete()clears, and the will. Inbound/setvalidation follows too: the topic check accepted onlyebusand now accepts the tree's own domain, so a device underhomiecan 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 newDevice.homie_domain(), and a child passing its own is refused with aValueErrorexactly as a child passing its ownmqtt_cfgis; refused even when the value would have matched, because the rule is structural rather than a value check. TheDevicedocstring's "homie_domains config for future use, not currently supported by this code" stub is replaced by what to actually do. (#61) -
DeviceSpecandDeviceTreeBuilder: a device-level declaration and a tree-aware, incremental builder, for publishers whose shape is a tree rather than one device.build_from_declarationsmaterializes 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,$descriptionand capability set. Three independent consumers had hand-rolled the same layer on top ofhomie.Device(parent=...), which is evidence about the SDK rather than about them. Device class, id and parent are device-level facts, so they live onDeviceSpecrather than being repeated on every property of the device;device_typedefaults toenergy.ebus.device.{device_class}, which matters more than a convenience default because the SDK storesDevice.typeverbatim and validates nothing against a registry, so the derived form is the main guard against a type that ships misspelled. The builder accepts aGroupedPropertyDictit does not own and keys each device's group by device rather than capability, since two children both exposinginfootherwise collide in the model while remaining perfectly distinct on the wire; aPropertySpecnaming its ownmodel_groupstill wins, so a consumer with an existing model keeps its keying. Late-bound ids are first class:device_idmay be a callable returningNonewhile an asynchronous identifier has not arrived,add()returnsNoneand remembers the spec, andresolve_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_createdcarries per-child side effects so consumers do not post-process the returned tree. (#57) -
PropertySpecreaches 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'svalues=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$descriptionstays honest and no/settopic is opened on a property that would reject the command); andsource_id/model_group, which split the observable-model identity from the wire identity. That last split is the load-bearing one:capabilitywas 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 exposeinfocollide 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:settablewithconditionally_settable, andinternal_onlywith either. (#58)
Fixed
bind_property_to_homienow 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 answerretained()is treated as retained, which is what every twin got before the distinction existed. Found while testing the newretainedfield, 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, andspecs_and_values()handsbuild_from_declarationsvalues that have already been scaled, which is exactly why the builder must not scale them again. Stated positively in both docstrings and indoc/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 avaluesmap by hand passes values in the property's own unit. -
doc/building-a-proxy.mdgains a "Beyond the basic fields" section: one row perPropertySpecfield 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
Changed
- CI: the
pytestandruffjobs, and the publish workflow's test gate, now carrytimeout-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.Propertynow takes a lock, and a lock regression deadlocks whichever thread reaches it (making it non-reentrant deadlocks the main thread at the firstset_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_contextpair with no lock held, while every other accessor on the class (including the observer dispatch) took itsRLock, 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$descriptionrepublish and a$statetransition, 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 nestedbulk_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) -
Propertynow 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 viaset_value(), and the MQTT loop thread viaon_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_valueand_ever_publishedwere 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 becauseset_value()callspublish_value()callsclear_value(), each taking it; a plainLockself-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'spublish(): 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.Devicemodeled 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 announcedisconnected, which is a lie, or reach around the SDK to the concrete client;DeviceState.LOSTwas published nowhere inhomie.pyexcept inside thewill()descriptor, and the will fires only on an unclean disconnect, which the clean disconnectstop()performs deliberately suppresses. It is TREE-level likewill()andstop(), 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 payloadwill()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, anddeclare_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 theDevicedoes not hold is exactly how a laterrefresh_tree()silently republishesreadyover it. It returns whether$stateactually moved, reusingset_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, sincepublish_and_flushis owned-only and off theMqttDeviceTransportsurface. Reported by @cayossarian, whose async-transport drain sequence shaped the contract, and adopted byebus-panel-simin place of the reach-around that produced four separate downstream bugs. (#46) -
Device.stop(announce=False): tear down without publishing anything, leaving the retained$stateexactly as it stands. The counterpart todeclare_lost(): the defaultannounce=Truewould overwrite a just-declaredlostwithdisconnected, and the state move now lives inside the announcing branch so it cannot. Namedannouncerather than thegracefula 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 staleready, 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 toDevice.clear_retained_topic()aimed at a value topic, leaves that assumption false and the nextset_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()andNode.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 andencode_empty_string(), which is why the SDK owns this rather than the caller:round_tolives inside the property, so two readings of0.14494210481643677and0.14501120000000001are genuinely different values that any caller-side change check calls "changed", and both publish0.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-onlyforcethreaded throughrefresh_tree(),publish_nodes(),Node.publish()andpublish_value()(all defaulting toTrueon the walk,Falseon 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, whichdoc/consuming-a-homie-tree.mdhas 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 (orNone), rather than the current value. It was a documented placeholder whose body wasreturn 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, gotFalseevery 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; usevalue()for that. (#50)
Documentation
- README, bring-your-own-transport: a transport must preserve publish order, and why.
MqttDeviceTransportsays nothing about ordering because the two transports shipped with the SDK cannot violate it (paho's thread andasyncio_drivereach pump one client), but a transport written against the protocol directly can, and one that starts a task perpublish()hands ordering to the scheduler. The SDK maintains ordering on a producer's behalf — a device's$descriptionprecedes the$state=readythat vouches for it, andrefresh_tree()publishes a device's$stateafter 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.mdsays 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: apublish()that enqueues is legitimate (every return is typedobjectbecause the SDK discards it), butDevice.stop()publishes the final$statewithout 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
Added
Controller.is_tree_complete(root_id)andController.set_on_tree_ready_callback(): a first-class answer to "has the declared tree fully described itself?".$state=readyis a per-device signal meaning "my own$descriptionis 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 islosthas still told you what it is, so useget_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 andNode.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 therefresh_tree()docstring rather than left implicit; the exception is not re-raised. (#36) -
Device.remove_node()andDevice.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 mirrorNode.add_property()always has. Deleting a property cleared the retained value topic but left the device inreadywith a$descriptionthat 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 insidedevice.state_transition()exactly as additions do, so N deletions still collapse to one$descriptionpublish. (#35)
v0.18.1
Added
doc/consuming-a-homie-tree.md: the controller/subscriber-side guide, and the counterpart todoc/building-a-proxy.md. It states the asymmetry the convention relies on and that this repo had never written down: a producer SHOULD minimize$stateand$descriptiontransitions (quality-of-implementation, best-effort), while a consumer MUST react to every one of them, unconditionally (correctness). Covers what$state=readydoes and does not promise (it means "my own$descriptionis 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$descriptionthat 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$statebefore recursing to its children, so a device announcedreadywhile the children its$descriptionnames had published nothing.on_connectcallsrefresh_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 aslost, Homie 5 makes every child of alostrootlosttoo, 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=readystill means "my own$descriptionis current", never "my children are present". Thanks to @cayossarian. (#31)
v0.18.0
Fixed
ebus_default_override: the customizer's state-of-charge entry was keyedbattery, which is not an eBus capability and never has been. State of charge lives inenergy.ebus.capability.soc, so a conformant device typing its node that way resolved to the keysoc, missed the table, and reached Home Assistant with neither adevice_classnor astate_classon its SoC: precisely the ambiguous bare percent the entry exists to resolve. The entry is re-keyedsocand its property ids corrected to the ones the capability actually defines.state-of-charge,powerandtemperatureare dropped: none is a property of any eBus capability, a battery's electrical power ismeter/active-power(already covered), and eBus does not model pack temperature. Note this removes thebatterykey rather than aliasing it, so a publisher that copied the old example's non-conformantenergy.ebus.capability.batterynode type should move tosoc. Thanks to @cayossarian. (#27)soc's energy properties are no longer emitted as accumulating registers.soe,total-energy-storageandloadup-headroomare reservoir levels that FALL on discharge, but unit inference seesWh/kWhand saysenergy+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'senergy_storagedevice class withstate_class: measurement, its level counterpart (same units, min/max/mean rather than a sum).info/nameplate-capacitygets the same correction, being a constant rather than a register. (#27)- Two
meterentries,imported-active-energyandexported-active-energy, named properties the meter capability has never defined at any version; they are removed. They were behaviorally inert (aWhproperty already infersenergy+total_increasing), but they were table-versus-catalog drift of the same kind as thebatterykey. The four cumulative reactive/apparent registers (imported-reactive-energy,exported-reactive-energy,apparent-energy-imported,apparent-energy-exported) now carrystate_class: total_increasing; Home Assistant has no energydevice_classforvarh/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-phasepower-factor-{a,b,c}forms are added alongside the system-levelpower-factoralready 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'sMqttClient.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.jsonre-synced against the specification (HEAD922b9f8), which it had drifted 49 commits behind: framework 0.5 -> 0.7,capabilities/info0.1 -> 0.2,capabilities/meter0.1 -> 0.2,devices/utility-meter0.3 -> 0.6,registries/capability-types0.11 -> 0.19,registries/device-types0.4 -> 0.5. Thesupportslist is unchanged: no framework feature was added or removed across 0.5 -> 0.7. Two substantive corrections beyond the version numbers:grid,status,demandandpower-qualityare now pinned directly, because utility-meter 0.6 split them out of the utility-meter data model into standalone catalogs and the oldnotesrationale ("covered by pinning utility-meter 0.3") therefore no longer held; andsoc0.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-types0.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, includingenergy.ebus.device.utility-meter, the type this repo's reference publisher emits. Reported by @cayossarian, who ran the specification'sdrift-report.pyagainst 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_METAagainst the machine-readablecapabilities/*.jsoncatalogs in a siblingspecificationcheckout and reports every mismatched name at once. It reads the specification's HEAD rather than thesynced_commitpinned 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_DIRpoints it elsewhere. Proposed by @cayossarian. (#27)