ebus-panel-sim 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/CHANGELOG.md +31 -0
  2. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/CONTRIBUTING.md +14 -0
  3. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/DESIGN.md +3 -1
  4. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/DEVELOPER.md +3 -4
  5. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/PKG-INFO +53 -5
  6. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/README.md +52 -4
  7. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/__init__.py +8 -1
  8. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/emitter.py +173 -20
  9. ebus_panel_sim-0.4.0/src/ebus_panel_sim/wire/_sdk_seam.py +169 -0
  10. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/graph_builder.py +76 -28
  11. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/conftest.py +23 -11
  12. ebus_panel_sim-0.4.0/tests/test_byo_transport.py +381 -0
  13. ebus_panel_sim-0.4.0/tests/test_documentation.py +173 -0
  14. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_teardown.py +22 -0
  15. ebus_panel_sim-0.3.2/src/ebus_panel_sim/wire/_sdk_seam.py +0 -88
  16. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.ebus-spec.json +0 -0
  17. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.github/CODEOWNERS +0 -0
  18. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.github/workflows/ci.yaml +0 -0
  19. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.github/workflows/publish.yml +0 -0
  20. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.gitignore +0 -0
  21. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.pre-commit-config.yaml +0 -0
  22. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.python-version +0 -0
  23. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/AUTHORS +0 -0
  24. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/LICENSE +0 -0
  25. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/examples/forty_tab_minimal.yaml +0 -0
  26. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/examples/run_forty_tab_minimal.py +0 -0
  27. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/pyproject.toml +0 -0
  28. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/__init__.py +0 -0
  29. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/tab_legs.py +0 -0
  30. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/energy_integrator.py +0 -0
  31. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/exceptions.py +0 -0
  32. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest.py +0 -0
  33. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest_physics.py +0 -0
  34. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/__init__.py +0 -0
  35. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/bess.py +0 -0
  36. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/load_shedding.py +0 -0
  37. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/protocol.py +0 -0
  38. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/panel_meter.py +0 -0
  39. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/py.typed +0 -0
  40. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/relay_resolver.py +0 -0
  41. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/snapshot.py +0 -0
  42. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/tick_inputs.py +0 -0
  43. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/__init__.py +0 -0
  44. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/bag_builder.py +0 -0
  45. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/breaker.json +0 -0
  46. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/charge-limit.json +0 -0
  47. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/connection.json +0 -0
  48. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/door.json +0 -0
  49. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/grid.json +0 -0
  50. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/info.json +0 -0
  51. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/load-shed.json +0 -0
  52. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/meter.json +0 -0
  53. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/pcs.json +0 -0
  54. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/power-flows.json +0 -0
  55. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed-forecast.json +0 -0
  56. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed.json +0 -0
  57. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/soc.json +0 -0
  58. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/status.json +0 -0
  59. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/switch.json +0 -0
  60. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/.gitkeep +0 -0
  61. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/bess.yaml +0 -0
  62. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/circuit.yaml +0 -0
  63. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/evse.yaml +0 -0
  64. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/lugs.yaml +0 -0
  65. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/mid.yaml +0 -0
  66. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/panel.yaml +0 -0
  67. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/pv.yaml +0 -0
  68. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping_loader.py +0 -0
  69. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profile_loader.py +0 -0
  70. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/.gitkeep +0 -0
  71. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/bess.json +0 -0
  72. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/circuit.json +0 -0
  73. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/evse.json +0 -0
  74. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/lugs.json +0 -0
  75. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/mid.json +0 -0
  76. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/panel.json +0 -0
  77. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/pv.json +0 -0
  78. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/circuit.json +0 -0
  79. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/evse.json +0 -0
  80. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/lugs.json +0 -0
  81. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/panel.json +0 -0
  82. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/property_bag.py +0 -0
  83. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/publisher.py +0 -0
  84. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/set_router.py +0 -0
  85. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/__init__.py +0 -0
  86. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/conventions/__init__.py +0 -0
  87. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/conventions/test_tab_legs.py +0 -0
  88. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_catalog_drift.py +0 -0
  89. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_connection.py +0 -0
  90. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_emitter_public_surface.py +0 -0
  91. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_energy_integrator.py +0 -0
  92. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_exceptions.py +0 -0
  93. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_manifest.py +0 -0
  94. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_manifest_physics.py +0 -0
  95. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_mid_placement.py +0 -0
  96. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_panel_meter.py +0 -0
  97. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_publish_tick.py +0 -0
  98. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_relay_resolver.py +0 -0
  99. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_shed_forecast.py +0 -0
  100. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_tick_inputs.py +0 -0
  101. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_variant.py +0 -0
  102. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_wire_units.py +0 -0
  103. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/__init__.py +0 -0
  104. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_circuit_energy_frame.py +0 -0
  105. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder.py +0 -0
  106. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder_topology.py +0 -0
  107. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_profile_mapping_validation.py +0 -0
  108. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_property_bag.py +0 -0
  109. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_sdk_seam.py +0 -0
  110. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_set_router.py +0 -0
  111. {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/uv.lock +0 -0
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-08-08
6
+
7
+ Adds bring-your-own-transport: a producer that already owns its MQTT connection can publish an eBus tree through it. Additive, so nothing existing changes; the minor bump reflects new public API rather than a break.
8
+
9
+ The caller obligations are the part to read before using it. An injected client bypasses the SDK's connect path, so the Last Will and the on-(re)connect republish are yours to wire, and the emitter cannot do either on your behalf. `Notes on the bring-your-own-transport path` below states each one and what it costs to skip it; the README carries the wiring order as a recipe that the test suite executes from the file itself.
10
+
11
+ ### Added
12
+
13
+ - **Bring-your-own-transport.** `Emitter(..., mqttc=client)` publishes the tree through a client the caller already owns, instead of having one built from `mqtt_cfg`. The two are mutually exclusive and passing both raises. This mirrors ebus-sdk's `Device(mqttc=...)` contract, and the case it serves is a host that cannot afford a second connection — a Home Assistant add-on, whose MQTT integration is `single_config_entry` and which forbids background threads (`ebus-mqtt-client` 0.4.0's `asyncio_driver()` covers pumping the loop). See the README's "Bring your own transport" section for the required wiring order, and the notes below for the behaviour that differs from the `mqtt_cfg` path.
14
+ - **`Emitter.lwt_settings(manifest)`** returns the Last Will to register on a client you intend to inject. A `staticmethod` because it has to be answerable before an `Emitter` exists: the will rides the MQTT CONNECT packet, so it must be on the client before the client connects, which is before that client can be handed to a constructor. The descriptor comes from the SDK's own `Device.will()` — the same function the SDK passes as `lwt=` when it builds a client itself — so a caller-registered will is identical to an SDK-registered one rather than merely similar, and the shape drops into `MqttClient(lwt=...)` unchanged. Without it an injected tree has no will at all, and an unclean death leaves consumers reading a stale retained `ready` indefinitely.
15
+ - **`Emitter.republish_tree()`** re-announces the whole retained tree, for wiring to an injected client's on-connect handler. The SDK registers this itself only for a client it built.
16
+
17
+ ### Fixed
18
+
19
+ - **`stop(graceful=False)` left the root Device holding `ready` on an injected transport.** 0.3.3 moved the state before publishing but sourced that call through `owned_client()`, which returns None for a caller-supplied client, so the whole ungraceful teardown was a no-op there: object on `ready`, nothing on the wire. Latent in 0.3.3, because `mqttc=` did not exist to reach it; reachable the moment this release adds it. `set_state` now runs before the ownership split. This is the one entry here describing a defect in released code, and it could not be triggered by a 0.3.3 user.
20
+
21
+ ### Notes on the bring-your-own-transport path
22
+
23
+ Behaviour that is specific to `mqttc=` and easy to get wrong. None of it is a change to the `mqtt_cfg` path, whose wire output is byte-identical to 0.3.3.
24
+
25
+ - **`start()` does not wait, and there is nothing to wait for.** The SDK never starts a client it did not build, so polling `is_connected()` would stall the very event loop such a client is likely driven on without changing the outcome. Values are retained; the tree goes out on the first `publish_tick`.
26
+ - **`stop()` never stops your client, on either path.** Ownership decides, not type — an injected client can itself be an `MqttClient`. It does still go mute: ebus-sdk's `Device.stop()` clears the root's transport reference regardless of ownership, so `republish_tree()` publishes nothing afterwards and an on-connect hook wired to it becomes a silent no-op on a still-live client. Build a new `Emitter` to resume.
27
+ - **Nothing re-announces your tree unless you wire it.** The SDK registers its on-(re)connect republish inside `connect_broker()`, below an `if self.mqttc` early return that an injected client always takes. Measured against a real broker with the retained store wiped: an injected tree came back 5 topics of 56, every `$description` missing, where an owned one came back all 56. `republish_tree()` is the remedy; the README recipe wires it.
28
+ - **Let your event loop turn before closing the client.** `stop(graceful=False)` queues the `lost` rather than flushing it — `wait_for_publish` would block the very thread that has to run `loop_write` for a client pumped by `asyncio_driver`. Closing the client in the same synchronous breath drops the message and leaves the retained tree on `ready`: deterministic, not a race. There is nothing to await; one turn of the loop is the whole remedy.
29
+
30
+ ## [0.3.3] - 2026-08-07
31
+
32
+ ### Fixed
33
+
34
+ - **`stop(graceful=False)` published `$state=lost` but left the root Device still holding `ready`.** The wire and the object disagreed, so anything re-announcing from that object republished `ready` straight over the `lost`. It now goes through the SDK's public `Device.set_state(DeviceState.LOST)` first, mirroring what `Device.stop()` already does for `disconnected`. The flushed publish that follows is deliberately the same retained value a second time: `set_state` uses the ordinary unflushed path, and on the owned path the connection closes immediately behind the call, so only a flushed publish is guaranteed to land. Costs one message at teardown. The observable retained outcome is unchanged (confirmed against a real broker: `p1=lost`); what changes is that it now survives a subsequent re-announce. Two tests pin it, both failing against 0.3.2. Found by [@cayossarian](https://github.com/cayossarian) while building on this code in [#17](https://github.com/electrification-bus/distribution-enclosure-simulator/pull/17).
35
+
5
36
  ## [0.3.2] - 2026-08-07
6
37
 
7
38
  Metadata-only. No source changes, so the published tree and the public API are identical to 0.3.1; this release exists to get the packaging metadata below onto PyPI, where it only takes effect on a publish.
@@ -39,6 +39,20 @@ Pull requests are welcome.
39
39
  - **Keep comments to a minimum.** The project style is self-explanatory code, with comments reserved for the non-obvious *why* (a spec quirk, a Homie nuance, a SPAN-variant deviation). Don't add comments that just restate the code.
40
40
  - One commit per logical change is fine; we don't require squash or any particular branch naming.
41
41
 
42
+ ## What "done" means
43
+
44
+ This package is published to PyPI, and a PyPI upload can never be replaced. So an increment that lands behaviour and leaves its documentation for later is not a smaller version of the change: it is a release whose docs contradict its code, and the correction costs another release. Ship the whole thing.
45
+
46
+ A change is done when, in the same PR:
47
+
48
+ - **Prose it invalidates is fixed.** If a change makes a sentence in the README, `DESIGN.md`, `DEVELOPER.md`, or a docstring untrue, that sentence is part of the change. A new option whose README still describes the old single path is not finished.
49
+ - **Types its signatures name are reachable.** A public parameter annotated with a type a caller cannot import from `ebus_panel_sim` forces them to depend on `ebus_sdk` directly, which is what the SDK seam exists to spare them.
50
+ - **It carries its `CHANGELOG.md` entry**, under `[Unreleased]` or a version heading.
51
+ - **New behaviour has a test that fails without it.** Say so in the PR, and say which. A test that passes against the previous commit is not evidence.
52
+ - **Caller obligations are written down where the caller will look.** If correct use requires the caller to do something (wire a callback, set something before connecting, let an event loop turn), a `#` comment in our source does not reach them.
53
+
54
+ Splitting a change is fine when the parts are genuinely independent, or when a decision is needed that only a maintainer can make. It is not fine as a way to defer the half that needs no decision. If you are unsure which you have, open the PR with the whole thing and let the review split it.
55
+
42
56
  ## Local development
43
57
 
44
58
  Python >= 3.11 (developed and CI-tested on 3.11 and 3.14), managed with [uv](https://docs.astral.sh/uv/). See [DEVELOPER.md](DEVELOPER.md) for the full guide.
@@ -17,7 +17,9 @@ The producer builds a `DeviceManifest` (identity plus physics keys per device) a
17
17
 
18
18
  The enclosure is a Homie root device (`energy.ebus.device.distribution-enclosure`); every circuit, lugs pair, and integrated DER (BESS, PV, EVSE, MID) is a separate child Homie device with `root` and `parent` back-references to the enclosure. Each device's properties are grouped into capability-typed nodes (`info`, `meter`, `switch`, `breaker`, `load-shed`, `pcs`, `connection`, `status`, `door`, `soc`, `shed`, `shed-forecast`, `grid`, `config`, `power-flows`), whose node `$type` is `energy.ebus.capability.<capability>`. A child device therefore publishes under its own topic root, for example `ebus/5/<circuit-id>/switch/relay` and `ebus/5/<bess-id>-mid/grid/islanding-state`.
19
19
 
20
- Placement is declarative. Each `wire/mapping/*.yaml` descriptor says whether its device class is the `root-device` or a `child-of-parent`; `graph_builder` walks the manifest, mappings, and profiles to build the SDK device graph, and the SDK's `Device` owns the `$state` cascade: `graph_builder` wraps each device's node/property build in a `state_transition()` that coalesces the description republish into a single `init` then `ready` cycle, while `Emitter.start()`/`stop()` drive connect and disconnect. A graceful `stop()` publishes only the root's `$state=disconnected` (by Homie's effective-state rule that covers every child); an ungraceful drop leaves the broker LWT to fire `$state=lost`; retained topics are cleared only when `stop(clear_retained=True)` is passed. The vendored `wire/profiles/*.json` are the schema (capabilities, properties, datatypes, units, `$format`, settability); `bag_builder` maps each profile-declared property to a snapshot accessor and fails loud at construction if any declared property has no source.
20
+ Placement is declarative. Each `wire/mapping/*.yaml` descriptor says whether its device class is the `root-device` or a `child-of-parent`; `graph_builder` walks the manifest, mappings, and profiles to build the SDK device graph, and the SDK's `Device` owns the `$state` cascade: `graph_builder` wraps each device's node/property build in a `state_transition()` that coalesces the description republish into a single `init` then `ready` cycle, while `Emitter.start()`/`stop()` drive connect and disconnect. A graceful `stop()` publishes only the root's `$state=disconnected` (by Homie's effective-state rule that covers every child); `stop(graceful=False)` publishes the root's `$state=lost` itself, because the registered LWT cannot deliver it (a will fires only on an *unclean* disconnect, and every teardown path here closes cleanly, deliberately, so an orderly shutdown is not reported as a crash); retained topics are cleared only when `stop(clear_retained=True)` is passed, which is graceful-only, since a producer that died clears nothing. The vendored `wire/profiles/*.json` are the schema (capabilities, properties, datatypes, units, `$format`, settability); `bag_builder` maps each profile-declared property to a snapshot accessor and fails loud at construction if any declared property has no source.
21
+
22
+ That description assumes the emitter owns the connection. With an injected transport (`Emitter(mqttc=...)`) the same teardown still moves the root to `$state=lost` and publishes it, but three things move to the caller, because the emitter never starts or stops a client it did not build. The LWT is registered by the caller before they connect (`Emitter.lwt_settings(manifest)` answers it without an instance, since the will rides the CONNECT packet); the on-(re)connect whole-tree republish is wired by the caller (`Emitter.republish_tree`), the SDK registering its own only inside `connect_broker()`, below the `if self.mqttc` early return an injected client always takes; and the ungraceful `lost` is *queued* on the caller's loop rather than flushed, since flushing would block the thread running `loop_write`, so the caller must let the loop turn before closing the client. Absent the first, the tree has no will and an unclean death leaves consumers on a stale retained `ready`; absent the second, a broker that loses its retained store never gets the tree back.
21
23
 
22
24
  ## Native devices
23
25
 
@@ -131,13 +131,12 @@ distribution-enclosure-simulator/
131
131
  bag_builder.py # Snapshot -> PropertyBag translator
132
132
  property_bag.py # Per-tick property values + diff cache
133
133
  publisher.py # Per-tick diff/publish loop
134
- lifecycle.py # $state, $description, /set subscription, LWT
135
134
  set_router.py # Setter registry and /set dispatch
136
- wire_paths.py # Homie topic-template helpers
137
- _sdk_seam.py # Internal seam over ebus_sdk.property
135
+ _sdk_seam.py # Internal seam over ebus_sdk (property build, owned-client narrowing, will publish)
138
136
  profiles/ # Vendored Homie 5 device profiles (JSON), per device type
139
137
  mapping/ # Vendored mapping descriptors (YAML), per device type
140
- tests/ # pytest suite (asyncio auto; in-process amqtt broker)
138
+ catalogs/ # Vendored spec capability catalogs (JSON); the wire type contract
139
+ tests/ # pytest suite; paho is patched, so no broker and no socket
141
140
  conftest.py
142
141
  conventions/ # convention tests
143
142
  wire/ # wire-layer tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebus-panel-sim
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Producer-side Homie publisher with embedded behaviour runtime for the eBus convention
5
5
  Project-URL: Homepage, https://ebus.energy
6
6
  Project-URL: Repository, https://github.com/electrification-bus/distribution-enclosure-simulator
@@ -148,10 +148,11 @@ def main() -> None:
148
148
  bess_cfg = BESSConfig(instance_id="abc-123-bess", nameplate_capacity_kwh=13.5,
149
149
  max_charge_w=3500.0, max_discharge_w=3500.0)
150
150
 
151
- # The emitter owns the MQTT connection: ebus-sdk builds the client from
152
- # mqtt_cfg and sets the enclosure's LWT. Empty SetterRegistry -> the emitter
153
- # installs internal default /set handlers; register your own before
154
- # construction to override them.
151
+ # With mqtt_cfg the emitter owns the MQTT connection: ebus-sdk builds the
152
+ # client and sets the enclosure's LWT. (Injecting your own client instead
153
+ # moves both of those to you — see "Bring your own transport" below.)
154
+ # Empty SetterRegistry -> the emitter installs internal default /set
155
+ # handlers; register your own before construction to override them.
155
156
  emitter = Emitter(
156
157
  manifest, SetterRegistry(),
157
158
  mqtt_cfg={"host": "127.0.0.1", "port": 1883},
@@ -176,6 +177,53 @@ main()
176
177
 
177
178
  Read the most recently published state back through `emitter.last_snapshot`. `mqtt_cfg` is handed straight to ebus-sdk: beyond `host`/`port` it takes the ebus-mqtt-client TLS and authentication keys for secured brokers (e.g. broker-quickstart's mTLS `discovery`/`strict` profiles).
178
179
 
180
+ ### Bring your own transport
181
+
182
+ A host that already owns an MQTT connection can publish through it instead of having a second one opened underneath: pass `Emitter(..., mqttc=client)` in place of `mqtt_cfg=`. The two are mutually exclusive. This mirrors ebus-sdk's own `Device(mqttc=...)`, and the case it exists for is a host like a Home Assistant add-on, whose MQTT integration is `single_config_entry` and which forbids background threads (`ebus-mqtt-client` 0.4.0's `asyncio_driver()` pumps paho's loop on yours).
183
+
184
+ **The emitter never starts or stops a client it did not build.** Two things it consequently cannot do for you — register the Last Will, and re-announce the tree on reconnect — are automatic on the `mqtt_cfg` path and yours here. They are steps 1 and 4 below, and the order is forced rather than stylistic:
185
+
186
+ ```python
187
+ from ebus_panel_sim import Emitter, SetterRegistry
188
+ from ebus_mqtt_client import MqttClient
189
+
190
+ # 1. The Last Will must exist before the client connects — it rides the CONNECT
191
+ # packet, so it cannot be attached afterwards. This is why it is a
192
+ # staticmethod: there is no Emitter yet, and cannot be.
193
+ lwt = Emitter.lwt_settings(manifest)
194
+
195
+ # 2. Build your client with it, still unconnected.
196
+ client = MqttClient.from_config({"host": "127.0.0.1", "port": 1883}, client_id="my-host", lwt=lwt)
197
+
198
+ # 3. Now the emitter, publishing through it.
199
+ emitter = Emitter(manifest, SetterRegistry(), mqttc=client)
200
+
201
+ # 4. Re-announce the whole tree on every (re)connect. Assigned after construction
202
+ # rather than passed to from_config, because the callback needs the emitter and
203
+ # the emitter needs the client. Invoked with no arguments.
204
+ client.on_connect_callback = emitter.republish_tree
205
+
206
+ # 5. You connect, not the emitter — it never starts a client it did not build.
207
+ client.start()
208
+ emitter.start() # returns immediately; it has no connection to wait for
209
+ ```
210
+
211
+ To pump paho on your own event loop instead of its background thread — the case a
212
+ Home Assistant add-on needs — replace step 5's `client.start()` with the driver,
213
+ which is `async` and mutually exclusive with `start()`:
214
+
215
+ ```python
216
+ driver = client.asyncio_driver() # must be called from inside a running loop
217
+ await driver.start()
218
+ emitter.start()
219
+ ```
220
+
221
+ What each buys, and one obligation that is about timing rather than wiring. All three are silent when they bite:
222
+
223
+ - **No will means no liveness signal.** Skip step 1 and the tree has no LWT at all: a host that dies leaves every consumer reading a stale retained `ready`, indefinitely. `stop(graceful=False)` publishes `$state=lost` itself, but that only covers an orderly teardown — the case where the process *didn't* die.
224
+ - **No re-announce means the tree does not come back.** Skip step 4 and a broker that loses its retained store never sees the tree again; what returns is whatever later ticks happen to republish. Measured after wiping a real broker's retained store: 5 topics of 56, every `$description` missing.
225
+ - **Let your loop turn before you close the client.** `stop(graceful=False)` *queues* the `lost` on your loop rather than flushing it — flushing would block the very thread that has to run `loop_write`. Closing the client in the same synchronous breath drops the message and leaves the retained tree on `ready`.
226
+
179
227
  ## Layout
180
228
 
181
229
  - `src/ebus_panel_sim/` — the package (`emitter.py`, `manifest.py`, `wire/` profiles + publishing, `native_devices/`); see [DESIGN.md](https://github.com/electrification-bus/distribution-enclosure-simulator/blob/main/DESIGN.md).
@@ -122,10 +122,11 @@ def main() -> None:
122
122
  bess_cfg = BESSConfig(instance_id="abc-123-bess", nameplate_capacity_kwh=13.5,
123
123
  max_charge_w=3500.0, max_discharge_w=3500.0)
124
124
 
125
- # The emitter owns the MQTT connection: ebus-sdk builds the client from
126
- # mqtt_cfg and sets the enclosure's LWT. Empty SetterRegistry -> the emitter
127
- # installs internal default /set handlers; register your own before
128
- # construction to override them.
125
+ # With mqtt_cfg the emitter owns the MQTT connection: ebus-sdk builds the
126
+ # client and sets the enclosure's LWT. (Injecting your own client instead
127
+ # moves both of those to you — see "Bring your own transport" below.)
128
+ # Empty SetterRegistry -> the emitter installs internal default /set
129
+ # handlers; register your own before construction to override them.
129
130
  emitter = Emitter(
130
131
  manifest, SetterRegistry(),
131
132
  mqtt_cfg={"host": "127.0.0.1", "port": 1883},
@@ -150,6 +151,53 @@ main()
150
151
 
151
152
  Read the most recently published state back through `emitter.last_snapshot`. `mqtt_cfg` is handed straight to ebus-sdk: beyond `host`/`port` it takes the ebus-mqtt-client TLS and authentication keys for secured brokers (e.g. broker-quickstart's mTLS `discovery`/`strict` profiles).
152
153
 
154
+ ### Bring your own transport
155
+
156
+ A host that already owns an MQTT connection can publish through it instead of having a second one opened underneath: pass `Emitter(..., mqttc=client)` in place of `mqtt_cfg=`. The two are mutually exclusive. This mirrors ebus-sdk's own `Device(mqttc=...)`, and the case it exists for is a host like a Home Assistant add-on, whose MQTT integration is `single_config_entry` and which forbids background threads (`ebus-mqtt-client` 0.4.0's `asyncio_driver()` pumps paho's loop on yours).
157
+
158
+ **The emitter never starts or stops a client it did not build.** Two things it consequently cannot do for you — register the Last Will, and re-announce the tree on reconnect — are automatic on the `mqtt_cfg` path and yours here. They are steps 1 and 4 below, and the order is forced rather than stylistic:
159
+
160
+ ```python
161
+ from ebus_panel_sim import Emitter, SetterRegistry
162
+ from ebus_mqtt_client import MqttClient
163
+
164
+ # 1. The Last Will must exist before the client connects — it rides the CONNECT
165
+ # packet, so it cannot be attached afterwards. This is why it is a
166
+ # staticmethod: there is no Emitter yet, and cannot be.
167
+ lwt = Emitter.lwt_settings(manifest)
168
+
169
+ # 2. Build your client with it, still unconnected.
170
+ client = MqttClient.from_config({"host": "127.0.0.1", "port": 1883}, client_id="my-host", lwt=lwt)
171
+
172
+ # 3. Now the emitter, publishing through it.
173
+ emitter = Emitter(manifest, SetterRegistry(), mqttc=client)
174
+
175
+ # 4. Re-announce the whole tree on every (re)connect. Assigned after construction
176
+ # rather than passed to from_config, because the callback needs the emitter and
177
+ # the emitter needs the client. Invoked with no arguments.
178
+ client.on_connect_callback = emitter.republish_tree
179
+
180
+ # 5. You connect, not the emitter — it never starts a client it did not build.
181
+ client.start()
182
+ emitter.start() # returns immediately; it has no connection to wait for
183
+ ```
184
+
185
+ To pump paho on your own event loop instead of its background thread — the case a
186
+ Home Assistant add-on needs — replace step 5's `client.start()` with the driver,
187
+ which is `async` and mutually exclusive with `start()`:
188
+
189
+ ```python
190
+ driver = client.asyncio_driver() # must be called from inside a running loop
191
+ await driver.start()
192
+ emitter.start()
193
+ ```
194
+
195
+ What each buys, and one obligation that is about timing rather than wiring. All three are silent when they bite:
196
+
197
+ - **No will means no liveness signal.** Skip step 1 and the tree has no LWT at all: a host that dies leaves every consumer reading a stale retained `ready`, indefinitely. `stop(graceful=False)` publishes `$state=lost` itself, but that only covers an orderly teardown — the case where the process *didn't* die.
198
+ - **No re-announce means the tree does not come back.** Skip step 4 and a broker that loses its retained store never sees the tree again; what returns is whatever later ticks happen to republish. Measured after wiping a real broker's retained store: 5 topics of 56, every `$description` missing.
199
+ - **Let your loop turn before you close the client.** `stop(graceful=False)` *queues* the `lost` on your loop rather than flushing it — flushing would block the very thread that has to run `loop_write`. Closing the client in the same synchronous breath drops the message and leaves the retained tree on `ready`.
200
+
153
201
  ## Layout
154
202
 
155
203
  - `src/ebus_panel_sim/` — the package (`emitter.py`, `manifest.py`, `wire/` profiles + publishing, `native_devices/`); see [DESIGN.md](https://github.com/electrification-bus/distribution-enclosure-simulator/blob/main/DESIGN.md).
@@ -63,13 +63,19 @@ from ebus_panel_sim.snapshot import (
63
63
  EbusPvSnapshot,
64
64
  )
65
65
  from ebus_panel_sim.tick_inputs import PanelEnvelopeTick, TickInputs
66
+
67
+ # `Emitter(mqttc=...)` is public API typed with this, so the name has to be
68
+ # nameable from here. Without it a downstream annotating what it passes must
69
+ # import from `ebus_sdk` directly, which is exactly the coupling the wire seam
70
+ # exists to spare it.
71
+ from ebus_panel_sim.wire._sdk_seam import MqttDeviceTransport
66
72
  from ebus_panel_sim.wire.set_router import SetterHandler, SetterRegistry
67
73
 
68
74
  # Single source of truth for the distribution version: pyproject reads it from here
69
75
  # via `[tool.hatch.version]`, and publish.yml refuses to release when the git tag
70
76
  # disagrees. Bump it in this one place. Note this is the PACKAGE version and is
71
77
  # distinct from the producer-contract version the docstrings above refer to.
72
- __version__ = "0.3.2"
78
+ __version__ = "0.4.0"
73
79
 
74
80
  __all__ = [
75
81
  "BESSConfig",
@@ -101,6 +107,7 @@ __all__ = [
101
107
  "ManifestPhysicsView",
102
108
  "ManifestValidationError",
103
109
  "MissingSetterError",
110
+ "MqttDeviceTransport",
104
111
  "NativeDevice",
105
112
  "NativeTickContext",
106
113
  "PanelEnvelopeTick",
@@ -49,9 +49,14 @@ from ebus_panel_sim.snapshot import (
49
49
  EbusPvSnapshot,
50
50
  )
51
51
  from ebus_panel_sim.tick_inputs import TickInputs
52
- from ebus_panel_sim.wire._sdk_seam import owned_client, publish_will_now
52
+ from ebus_panel_sim.wire._sdk_seam import (
53
+ MqttDeviceTransport,
54
+ owned_client,
55
+ publish_will_now,
56
+ will_for_root_id,
57
+ )
53
58
  from ebus_panel_sim.wire.bag_builder import BagBuilder
54
- from ebus_panel_sim.wire.graph_builder import build_graph
59
+ from ebus_panel_sim.wire.graph_builder import build_graph, root_instance_of
55
60
  from ebus_panel_sim.wire.mapping_loader import load_mapping_table
56
61
  from ebus_panel_sim.wire.profile_loader import Variant, load_profiles
57
62
  from ebus_panel_sim.wire.publisher import Publisher
@@ -74,12 +79,34 @@ class Emitter:
74
79
  setter_registry: SetterRegistry,
75
80
  *,
76
81
  mqtt_cfg: dict[str, Any] | None = None,
82
+ mqttc: MqttDeviceTransport | None = None,
77
83
  bess_configs: tuple[BESSConfig, ...] = (),
78
84
  load_shedding_config: LoadSheddingConfig | None = None,
79
85
  variant: Variant = "span",
80
86
  ) -> None:
81
87
  self._manifest = manifest
82
- self._mqtt_cfg = dict(mqtt_cfg) if mqtt_cfg is not None else dict(_DEFAULT_MQTT_CFG)
88
+ # Bring-your-own-transport: a caller that already owns a connected client
89
+ # passes it as ``mqttc`` and the SDK publishes through it, never starting
90
+ # or stopping it. Mutually exclusive with ``mqtt_cfg``, which has the SDK
91
+ # build a client it owns.
92
+ #
93
+ # Registering the Last Will on an injected client is the CALLER'S job, and
94
+ # has to happen before they connect it: the will rides the MQTT CONNECT
95
+ # packet, so neither the SDK nor this emitter can attach one to a client
96
+ # handed over already connected (``Device.will()`` is exposed for exactly
97
+ # this, and returns the descriptor to register). Without it the tree can
98
+ # still reach ``$state=lost`` through ``stop(graceful=False)``, which
99
+ # publishes the same payload itself — but that covers orderly teardown
100
+ # only. A caller that wants a *dropped* process to show as lost, which is
101
+ # what a will is for, must register it.
102
+ if mqttc is not None and mqtt_cfg is not None:
103
+ raise EmitterStateError("Emitter takes mqtt_cfg= or mqttc=, not both")
104
+ self._mqttc = mqttc
105
+ self._owns_client = mqttc is None
106
+ if mqttc is not None:
107
+ self._mqtt_cfg = None
108
+ else:
109
+ self._mqtt_cfg = dict(mqtt_cfg) if mqtt_cfg is not None else dict(_DEFAULT_MQTT_CFG)
83
110
 
84
111
  # variant="span" (default) publishes the SPAN-faithful surface (status
85
112
  # diagnostics, read-only shed/policy, the legacy evse config); "reference"
@@ -88,9 +115,13 @@ class Emitter:
88
115
  self._mapping = load_mapping_table()
89
116
  self._mapping.validate_against(self._profiles)
90
117
 
91
- # The root device owns the shared MQTT connection built from mqtt_cfg;
92
- # children share it. Construction opens no socket (deferred to start()).
93
- self._graph = build_graph(manifest, self._mapping, self._profiles, mqtt_cfg=self._mqtt_cfg)
118
+ # The root device holds the shared connection — built from mqtt_cfg, or
119
+ # the caller's when injected — and children publish through it either
120
+ # way. Construction opens no socket: an owned client connects in start(),
121
+ # an injected one is already the caller's to connect.
122
+ self._graph = build_graph(
123
+ manifest, self._mapping, self._profiles, mqtt_cfg=self._mqtt_cfg, mqttc=self._mqttc
124
+ )
94
125
  self._root = self._graph.devices[self._graph.root_id]
95
126
 
96
127
  # ---- native-device + physics state (must exist before internal /set
@@ -188,14 +219,104 @@ class Emitter:
188
219
  )
189
220
  )
190
221
 
191
- def start(self, *, connect_timeout_s: float = 5.0) -> None:
192
- """Open the MQTT connection and publish the retained device tree.
222
+ @staticmethod
223
+ def lwt_settings(manifest: DeviceManifest) -> dict[str, str]:
224
+ """The Last Will to register on a client you intend to inject.
225
+
226
+ A ``staticmethod`` taking the manifest, because it has to be answerable
227
+ *before* there is an ``Emitter`` to ask: the will rides the MQTT CONNECT
228
+ packet, so it must be on the client before the client connects, which is
229
+ before you can hand that client to a constructor. An instance method here
230
+ would be unusable by definition.
231
+
232
+ The root is derived from the same mapping table ``build_graph`` uses, so
233
+ the will names the device the tree will actually publish as, and the
234
+ descriptor itself comes from the SDK's ``Device.will()`` — the very
235
+ function the SDK passes as ``lwt=`` when it builds a client of its own.
236
+ A caller-registered will is therefore identical to an SDK-registered one
237
+ rather than merely similar.
238
+
239
+ The shape drops into ``MqttClient(lwt=...)`` unchanged. The full wiring,
240
+ in the order it has to happen::
241
+
242
+ from ebus_mqtt_client import MqttClient
193
243
 
194
- ebus-sdk publishes each device's ``$description`` + ``$state`` and
195
- subscribes the ``/set`` topics itself once the link comes up (on_connect
196
- -> refresh_tree). We start the root's shared client and wait, bounded,
197
- for the link so the first ``publish_tick`` lands live; publishing before
198
- connect is still safe (values are retained and republished on connect)."""
244
+ from ebus_panel_sim import Emitter, SetterRegistry
245
+
246
+ lwt = Emitter.lwt_settings(manifest)
247
+ client = MqttClient.from_config(
248
+ {"host": "127.0.0.1", "port": 1883}, client_id="my-host", lwt=lwt
249
+ )
250
+ emitter = Emitter(manifest, SetterRegistry(), mqttc=client)
251
+ client.on_connect_callback = emitter.republish_tree
252
+ client.start()
253
+
254
+ ``on_connect_callback`` is assigned after construction rather than passed
255
+ to ``from_config`` because the callback needs the emitter and the emitter
256
+ needs the client; omitting it leaves the tree unable to come back after a
257
+ broker restart. See the README's "Bring your own transport" section for
258
+ what each step buys.
259
+
260
+ Without the will the tree has none at all on an injected transport, and an
261
+ ungraceful death leaves consumers reading a stale retained ``ready``
262
+ indefinitely. ``stop(graceful=False)`` covers only orderly teardown.
263
+ """
264
+ root = root_instance_of(manifest, load_mapping_table())
265
+ return will_for_root_id(root.instance_id)
266
+
267
+ def republish_tree(self) -> None:
268
+ """Re-announce the whole retained tree. Wire this to your client's
269
+ on-connect handler when you inject a transport.
270
+
271
+ For a client the SDK builds, it registers this itself inside
272
+ ``connect_broker()`` and every reconnect republishes automatically. That
273
+ registration sits *below* an ``if self.mqttc: return``, so an injected
274
+ client never reaches it — the SDK says as much, and asks the caller to
275
+ call it from their own on-connect handler.
276
+
277
+ Nothing else covers the gap. Retained values survive on the broker, but a
278
+ broker that loses its retained store (restart, eviction, a fresh
279
+ deployment) drops the tree, and only a re-announce brings it back. What
280
+ comes back without this is whatever later ticks happen to republish:
281
+ measured against a real broker, 5 topics of 56, with every
282
+ ``$description`` missing.
283
+ """
284
+ self._root.refresh_tree()
285
+
286
+ def start(self, *, connect_timeout_s: float = 5.0) -> None:
287
+ """Mark the emitter ready to publish; with ``mqtt_cfg=``, open the connection too.
288
+
289
+ The two paths differ in almost everything this method does, so the
290
+ summary above deliberately promises only what both deliver.
291
+
292
+ **Owned (``mqtt_cfg=``)** — opens the MQTT connection and publishes the
293
+ retained device tree. ebus-sdk publishes each device's ``$description`` +
294
+ ``$state`` and subscribes the ``/set`` topics itself once the link comes
295
+ up (on_connect -> refresh_tree). We start the root's shared client and
296
+ wait, bounded, for the link so the first ``publish_tick`` lands live;
297
+ publishing before connect is still safe, since values are retained and
298
+ the SDK's own on-connect hook republishes the tree.
299
+
300
+ **Injected (``mqttc=``)** — opens nothing and publishes nothing. The
301
+ caller connects their own client, and the tree goes out on the first
302
+ ``publish_tick``. Returns immediately; see the comment below for why
303
+ waiting would be wrong rather than merely pointless."""
304
+ if not self._owns_client:
305
+ # The caller owns the connection and its timing. Blocking here would
306
+ # stall the very loop an injected client is likely being driven on,
307
+ # and the SDK never starts a client it did not build, so there is
308
+ # nothing to wait for.
309
+ #
310
+ # Note what does NOT rescue an early publish here: the SDK registers
311
+ # its on-connect republish inside connect_broker(), which returns at
312
+ # `if self.mqttc` before reaching that registration for an injected
313
+ # client. So nothing republishes on reconnect unless the caller wired
314
+ # `republish_tree` themselves -- see that method, and the README's
315
+ # "Bring your own transport" wiring order. Measured against a real
316
+ # broker: after wiping the retained store, an injected tree came back
317
+ # 5 topics of 56, an owned one all 56.
318
+ self._started = True
319
+ return
199
320
  self._root.start_mqtt_client()
200
321
  deadline = time.monotonic() + connect_timeout_s
201
322
  while not self._root.is_connected() and time.monotonic() < deadline:
@@ -248,11 +369,25 @@ class Emitter:
248
369
  self._bess[instance_id].set_soe(soe_kwh)
249
370
 
250
371
  def stop(self, *, graceful: bool = True, clear_retained: bool = False) -> None:
251
- """Tear down the MQTT connection.
252
-
253
- Graceful (default): publish the root's ``$state=disconnected`` then stop
254
- the shared client (ebus-sdk's bounded teardown; per Homie's
255
- effective-state rule the root going disconnected covers every child).
372
+ """Take the tree down. With ``mqtt_cfg=``, tear down the connection too.
373
+
374
+ Graceful (default): publish the root's ``$state=disconnected``; per
375
+ Homie's effective-state rule the root going disconnected covers every
376
+ child. For a client this emitter built, that is followed by ebus-sdk's
377
+ bounded teardown, which stops it.
378
+
379
+ A caller-injected client is **never** stopped — the emitter did not build
380
+ it and may not be its only user. Two consequences a BYO caller has to
381
+ know, because neither is visible:
382
+
383
+ * The connection stays open and connected after this returns. Closing it
384
+ is yours to do, and yours to time (see the non-graceful note below).
385
+ * The emitter goes mute regardless. ebus-sdk's ``Device.stop()`` clears
386
+ the root's transport reference on both paths, so ``republish_tree()``
387
+ silently publishes nothing afterwards. If you wired it to your client's
388
+ on-connect handler, as the README's recipe does, that hook is still
389
+ attached to a live client and is now a no-op. Build a new ``Emitter``
390
+ to resume publishing.
256
391
 
257
392
  Non-graceful: leave the tree looking like a producer that died, by
258
393
  publishing the root's ``$state=lost`` retained before dropping the
@@ -268,13 +403,31 @@ class Emitter:
268
403
  waits on keepalive expiry. A consumer exercising the *retained* view sees
269
404
  the same thing either way; one exercising live will delivery does not.
270
405
 
406
+ **On an injected transport, let the loop turn before you close the
407
+ client.** The ``lost`` is queued on the caller's loop, not flushed — the
408
+ emitter cannot flush it, and would not want to: ``wait_for_publish``
409
+ blocks the very thread that has to run ``loop_write`` for a client pumped
410
+ by ``asyncio_driver``. Closing the client in the same synchronous breath
411
+ as this call therefore drops the message and leaves the retained tree on
412
+ ``ready``, deterministically. There is nothing to await: letting the loop
413
+ turn once before you close the client is the whole remedy.
414
+
271
415
  ``clear_retained`` additionally clears every device's retained values +
272
416
  ``$description`` before disconnecting, for a clean-slate re-run. It
273
417
  applies to the graceful path only, since a producer that died clears
274
418
  nothing."""
275
419
  if not graceful:
276
- publish_will_now(self._root)
277
- client = owned_client(self._root.mqttc)
420
+ # Only ever stop a client this emitter had built for it. Stopping an
421
+ # injected one would tear down a connection the caller owns and may
422
+ # be using for other things. Ownership is what decides, not the type:
423
+ # an injected client can itself be an ``MqttClient`` (a caller driving
424
+ # one on its own event loop via ``asyncio_driver``), so the narrowing
425
+ # below only makes the ``stop()`` call well-typed.
426
+ client = owned_client(self._root.mqttc) if self._owns_client else None
427
+ # Before the stop, not after: an owned client's connection is gone
428
+ # once it returns. The injected path publishes too — see
429
+ # ``publish_will_now``, which is where the two differ.
430
+ publish_will_now(self._root, owned=client)
278
431
  if client is not None:
279
432
  client.stop()
280
433
  return