Skip to content

Commit f1464ca

Browse files
dcjclaude
andauthored
docs: add the consumer-side contract guide (consuming-a-homie-tree) (#33)
States the asymmetry the convention relies on and that this repo had never written down: a producer SHOULD minimize $state/$description transitions (quality-of-implementation, best-effort), while a consumer MUST react to every one of them, unconditionally (correctness). Covers why the asymmetry exists (the four optimizations this SDK already grants itself as a producer all change WHEN and WHETHER a message appears, never what is true), what $state=ready does and does not promise, 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. Prompted by #31, where a careful integrator read our observable behaviour, inferred a timing contract from it, and built against the inference. The absence of a stated SHOULD/MUST split is what made that inference look safe. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6d9e20b commit f1464ca

3 files changed

Lines changed: 114 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- [`doc/consuming-a-homie-tree.md`](doc/consuming-a-homie-tree.md): the controller/subscriber-side guide, and the counterpart to [`doc/building-a-proxy.md`](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.
10+
711
### Fixed
812

913
- `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](https://github.com/cayossarian). ([#31](https://github.com/electrification-bus/python-sdk/issues/31))

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ model.set_value('meter', 'active-power', 1850.0)
158158

159159
**If you are building a proxy, read [`doc/building-a-proxy.md`](doc/building-a-proxy.md) first.** It is the comprehensive guide: declarative property definitions, the bridge-root plus proxied-children topology, dynamic device shapes, settable/bidirectional properties, and the anti-pattern to avoid (driving `homie.Device` directly from your data path). `examples/utility-meter` is the fullest worked example.
160160

161+
**If you are writing a controller or any subscriber, read [`doc/consuming-a-homie-tree.md`](doc/consuming-a-homie-tree.md) first.** It states the producer/consumer asymmetry the convention relies on (a producer SHOULD minimize `$state` and `$description` transitions; a consumer MUST react to every one of them unconditionally), what `$state = ready` does and does not promise, and the three ways consumers get this wrong. If you are about to gate on a root's `$state`, that document is the one you need.
162+
161163
**Home Assistant interop** is covered by two `ebus_sdk.ha` guides: [`doc/ha-mqtt-discovery.md`](doc/ha-mqtt-discovery.md) parses HA MQTT discovery INTO eBus, and [`doc/ha-discovery-bridge.md`](doc/ha-discovery-bridge.md) emits eBus OUT to HA via `HaDiscoveryBridge` (per-device mapping, an eBus-aware customizer, and HA <-> eBus loop-avoidance guards). `examples/ha-discovery-bridge` is a live-broker, no-HASS-needed demo.
162164

163165
### Controller Role

doc/consuming-a-homie-tree.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Consuming an eBus / Homie 5 tree
2+
3+
This is the guide for the **controller / subscriber** side: code that discovers devices on a broker and acts on what they publish. If you are on the publishing side, read [`doc/building-a-proxy.md`](building-a-proxy.md) instead.
4+
5+
Read this before you write your own parser. Nearly every integration bug we have seen on the consumer side comes from inferring a timing contract that the producer never offered, and the inference always looks safe until it isn't.
6+
7+
## TL;DR
8+
9+
**A producer SHOULD minimize `$state` and `$description` transitions. A consumer MUST react to every `$state` and `$description` update, unconditionally.**
10+
11+
Those are not two halves of one contract. The first is quality-of-implementation and is best-effort. The second is correctness and is unconditional. Reconcile from the state you currently hold on every update you receive; never wait for one message *because* you saw another.
12+
13+
## Why the asymmetry exists
14+
15+
A producer is licensed to coalesce, defer, suppress, and reorder its publishes in the name of not making every controller on the broker resync. That license is not a courtesy the SDK might withdraw: it is the whole reason `$description` republishes are cheap enough to be safe. The consequence is that **the observable message stream is a producer implementation detail**, so a consumer that learned a pattern from one release's producer breaks on the next.
16+
17+
Optimizations this SDK already grants itself as a producer:
18+
19+
| Optimization | Effect on the wire |
20+
| --- | --- |
21+
| Transaction collapsing (`state_transition()`) | N structural changes become one `init` to `ready` edge, not N |
22+
| Description suppression (content hash) | An unchanged `$description` is not republished at all |
23+
| Deferral inside an open transition | Interim `$description` publishes never reach the broker |
24+
| Cascade ordering (`refresh_tree()`) | A device's own `$state` follows the content it vouches for |
25+
26+
Every one of those changes *when and whether* a message appears. None of them changes what is true. A consumer that reads current state and reconciles is unaffected by all four; a consumer that awaits an expected message is broken by at least three.
27+
28+
## What `$state = ready` actually means
29+
30+
`ready` is a statement about **one device's own self-description**: *my `$description` is current, you may act on it.* That is all.
31+
32+
It is specifically **not**:
33+
34+
- a claim that the devices named in this device's `children` have published anything yet
35+
- a rollup over the subtree
36+
- a barrier after which the tree has stopped changing
37+
38+
The rollup reading is the common one and it cannot be made true by any producer. Children are commissioned and decommissioned out of band, so at any instant a new child may be mid-publish. **There is no moment at which a producer knows it has published "all" its children.** The strongest guarantee any producer can offer is per-transaction: *within this cascade, my announcement follows the content it announces.* That is a statement about a transaction, not about the world.
39+
40+
The one thing a root's state **is** authoritative for is **effective state**. Homie 5 propagates a non-ready root down the tree: when the root is `init`, `disconnected`, `sleeping`, or `lost`, its descendants are effectively that too, because the root is the gateway. Only when the root is `ready` do a child's own reported states stand. `Controller.get_effective_state(device_id)` implements this. Note it keys on the **root**, not on the immediate parent, so a mid-tree device entering a `state_transition()` does not mask its own children.
41+
42+
## What a consumer MUST do
43+
44+
- **React to every `$state` update**, including transitions you did not expect and repeats of a state you already hold.
45+
- **React to every `$description` update**, and re-derive your model from it rather than diffing against assumptions about what changed.
46+
- **Treat a device named in `children` as declared, not present.** Subscribe to it and wait; do not read it.
47+
- **Handle a tree that grows and shrinks after you first considered it complete.** Commissioning is an ongoing event, not a startup phase.
48+
- **Carry a timeout.** A declared child may never appear, because it crashed or its own LWT fired. "Declared" can never be made to mean "present" by any amount of producer-side ordering.
49+
50+
## Failure mode 1: the one-shot barrier
51+
52+
> "Wait for every declared descendant to describe itself, then report ready."
53+
54+
This is the most common defence and it is a real improvement over gating on the root alone. It is still wrong, because it is a **barrier** rather than a **loop**: it runs once, passes, and stops reconciling. Commission circuit #38 a minute later and the consumer never sees it. The failure moves from startup to steady state, which makes it harder to find, not less real.
55+
56+
Correct version: reconcile the declared child set against the subscribed child set on **every** ready edge, forever.
57+
58+
## Failure mode 2: waiting for a `$description` that never arrives
59+
60+
> "On the `init` to `ready` edge, wait for the new `$description`."
61+
62+
The content-hash suppression makes an unchanged `$description` a no-op, but it does **not** suppress the `init` to `ready` edge of an otherwise-empty transition. So a consumer can observe a ready edge with no `$description` following it, ever, and a consumer that awaits the pairing hangs.
63+
64+
Correct version: on the ready edge, act on the `$description` you already hold. The edge means "what you have is now current", not "a new one is coming".
65+
66+
## Failure mode 3: inferring order across retained messages
67+
68+
Publish order does not survive retention. A consumer that connects after the producer receives the retained tree in broker-chosen order, and the delivery order of a retained set on `SUBSCRIBE` is unspecified. Producer-side ordering only constrains what a consumer sees if it was **already subscribed** when the publishes happened.
69+
70+
This matters most in exactly the scenario people reason about first: a broker restart, where a previously-healthy consumer re-reads retained state. No producer-side ordering fix reaches that case.
71+
72+
Correct version: never derive an ordering expectation from the wire at all. Reconcile from current state.
73+
74+
## The shape that works
75+
76+
Reconcile-from-current-state instead of wait-for-event-pairing:
77+
78+
```python
79+
def on_state(device_id, state):
80+
if state != "ready":
81+
return
82+
# Act on the description we ALREADY hold. Do not await a fresh one.
83+
declared = controller.get_device(device_id).description.get("children", [])
84+
for child_id in declared:
85+
if child_id not in subscribed:
86+
subscribe(child_id) # its own retained $state/$description
87+
subscribed.add(child_id) # will cascade back through on_state
88+
for child_id in subscribed - set(declared):
89+
unsubscribe(child_id) # decommissioned
90+
```
91+
92+
The property that makes this correct is that it is **idempotent and order-independent**. Run it on every ready edge, in any order, as many times as you like, and it converges. It does not care whether the child published before or after its parent, whether the description was suppressed, or whether the messages arrived live or retained.
93+
94+
## What `Controller` already does for you
95+
96+
The SDK's `Controller` implements the above in tree-rooted mode. `_reconcile_descendants` fires on each `init` to `ready` edge and diffs the announced `children` against what is subscribed: added children get full topic subscriptions (their own retained state and description then cascade through the same handler, surfacing grandchildren), removed children are unsubscribed and dropped recursively. It also reconciles on a `$description` update for a device already in `ready`. That is why a `Controller`-based consumer was never exposed to the ordering defect fixed in 0.18.1: it uses `ready` as a trigger to subscribe, not as a barrier to read.
97+
98+
**Known gap:** there is no public "the declared tree is fully described" affordance. `Controller` reconciles correctly but does not expose a tree-complete signal, so a consumer that genuinely needs one (a topology snapshot, a one-shot export) currently has to build it. If that is you, build it as a reconciling predicate evaluated on every update, not as a barrier awaited once.
99+
100+
## Checklist
101+
102+
- [ ] Every `$state` update is handled, including unexpected and repeated states.
103+
- [ ] Every `$description` update re-derives the model rather than assuming a delta.
104+
- [ ] Declared children are subscribed and awaited, never read directly.
105+
- [ ] Reconciliation runs on every ready edge, not once at startup.
106+
- [ ] Nothing in the consumer awaits message B because message A arrived.
107+
- [ ] A declared child that never appears is handled by timeout, not by hanging.
108+
- [ ] Effective state is read via `get_effective_state()` rather than a device's own `$state`.

0 commit comments

Comments
 (0)