ebus-panel-sim 0.3.3__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.3 → ebus_panel_sim-0.4.0}/CHANGELOG.md +25 -0
  2. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/DESIGN.md +2 -0
  3. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/PKG-INFO +53 -5
  4. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/README.md +52 -4
  5. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/__init__.py +8 -1
  6. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/emitter.py +173 -20
  7. ebus_panel_sim-0.4.0/src/ebus_panel_sim/wire/_sdk_seam.py +169 -0
  8. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/graph_builder.py +76 -28
  9. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/conftest.py +23 -11
  10. ebus_panel_sim-0.4.0/tests/test_byo_transport.py +381 -0
  11. ebus_panel_sim-0.4.0/tests/test_documentation.py +173 -0
  12. ebus_panel_sim-0.3.3/src/ebus_panel_sim/wire/_sdk_seam.py +0 -102
  13. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.ebus-spec.json +0 -0
  14. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.github/CODEOWNERS +0 -0
  15. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.github/workflows/ci.yaml +0 -0
  16. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.github/workflows/publish.yml +0 -0
  17. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.gitignore +0 -0
  18. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.pre-commit-config.yaml +0 -0
  19. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.python-version +0 -0
  20. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/AUTHORS +0 -0
  21. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/CONTRIBUTING.md +0 -0
  22. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/DEVELOPER.md +0 -0
  23. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/LICENSE +0 -0
  24. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/examples/forty_tab_minimal.yaml +0 -0
  25. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/examples/run_forty_tab_minimal.py +0 -0
  26. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/pyproject.toml +0 -0
  27. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/__init__.py +0 -0
  28. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/tab_legs.py +0 -0
  29. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/energy_integrator.py +0 -0
  30. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/exceptions.py +0 -0
  31. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest.py +0 -0
  32. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest_physics.py +0 -0
  33. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/__init__.py +0 -0
  34. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/bess.py +0 -0
  35. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/load_shedding.py +0 -0
  36. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/protocol.py +0 -0
  37. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/panel_meter.py +0 -0
  38. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/py.typed +0 -0
  39. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/relay_resolver.py +0 -0
  40. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/snapshot.py +0 -0
  41. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/tick_inputs.py +0 -0
  42. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/__init__.py +0 -0
  43. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/bag_builder.py +0 -0
  44. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/breaker.json +0 -0
  45. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/charge-limit.json +0 -0
  46. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/connection.json +0 -0
  47. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/door.json +0 -0
  48. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/grid.json +0 -0
  49. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/info.json +0 -0
  50. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/load-shed.json +0 -0
  51. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/meter.json +0 -0
  52. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/pcs.json +0 -0
  53. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/power-flows.json +0 -0
  54. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed-forecast.json +0 -0
  55. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed.json +0 -0
  56. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/soc.json +0 -0
  57. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/status.json +0 -0
  58. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/switch.json +0 -0
  59. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/.gitkeep +0 -0
  60. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/bess.yaml +0 -0
  61. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/circuit.yaml +0 -0
  62. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/evse.yaml +0 -0
  63. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/lugs.yaml +0 -0
  64. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/mid.yaml +0 -0
  65. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/panel.yaml +0 -0
  66. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/pv.yaml +0 -0
  67. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping_loader.py +0 -0
  68. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profile_loader.py +0 -0
  69. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/.gitkeep +0 -0
  70. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/bess.json +0 -0
  71. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/circuit.json +0 -0
  72. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/evse.json +0 -0
  73. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/lugs.json +0 -0
  74. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/mid.json +0 -0
  75. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/panel.json +0 -0
  76. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/pv.json +0 -0
  77. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/circuit.json +0 -0
  78. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/evse.json +0 -0
  79. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/lugs.json +0 -0
  80. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/panel.json +0 -0
  81. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/property_bag.py +0 -0
  82. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/publisher.py +0 -0
  83. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/set_router.py +0 -0
  84. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/__init__.py +0 -0
  85. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/conventions/__init__.py +0 -0
  86. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/conventions/test_tab_legs.py +0 -0
  87. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_catalog_drift.py +0 -0
  88. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_connection.py +0 -0
  89. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_emitter_public_surface.py +0 -0
  90. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_energy_integrator.py +0 -0
  91. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_exceptions.py +0 -0
  92. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_manifest.py +0 -0
  93. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_manifest_physics.py +0 -0
  94. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_mid_placement.py +0 -0
  95. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_panel_meter.py +0 -0
  96. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_publish_tick.py +0 -0
  97. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_relay_resolver.py +0 -0
  98. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_shed_forecast.py +0 -0
  99. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_teardown.py +0 -0
  100. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_tick_inputs.py +0 -0
  101. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_variant.py +0 -0
  102. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_wire_units.py +0 -0
  103. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/__init__.py +0 -0
  104. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_circuit_energy_frame.py +0 -0
  105. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder.py +0 -0
  106. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder_topology.py +0 -0
  107. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_profile_mapping_validation.py +0 -0
  108. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_property_bag.py +0 -0
  109. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_sdk_seam.py +0 -0
  110. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_set_router.py +0 -0
  111. {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/uv.lock +0 -0
@@ -2,6 +2,31 @@
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
+
5
30
  ## [0.3.3] - 2026-08-07
6
31
 
7
32
  ### Fixed
@@ -19,6 +19,8 @@ The enclosure is a Homie root device (`energy.ebus.device.distribution-enclosure
19
19
 
20
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
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.
23
+
22
24
  ## Native devices
23
25
 
24
26
  Two device classes are not pure publishers: their behaviour runs inside the emitter.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebus-panel-sim
3
- Version: 0.3.3
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.3"
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
@@ -0,0 +1,169 @@
1
+ """Internal seam over ebus_sdk property construction.
2
+
3
+ Localises Property construction so a future SDK change to the property-dict
4
+ shape touches one file. NOT an abstraction layer: other modules hold and mutate
5
+ ``ebus_sdk.Property`` instances directly (the publisher calls ``Property.set_value``;
6
+ the SDK owns ``/set`` decode and value encoding).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import ebus_sdk
14
+ from ebus_sdk import (
15
+ EBUS_HOMIE_MQTT_QOS,
16
+ DeviceState,
17
+ MqttClient,
18
+ MqttDeviceTransport,
19
+ PropertyDatatype,
20
+ Unit,
21
+ )
22
+
23
+ # Re-exported so ``emitter.py`` can name the injection point's type without
24
+ # importing ebus_sdk itself, which is the property this seam exists to preserve.
25
+ __all__ = [
26
+ "MqttDeviceTransport",
27
+ "make_property",
28
+ "owned_client",
29
+ "publish_will_now",
30
+ "will_for_root_id",
31
+ ]
32
+
33
+
34
+ def make_property(
35
+ *,
36
+ node: ebus_sdk.Node,
37
+ key: str,
38
+ name: str,
39
+ datatype: PropertyDatatype,
40
+ unit: Unit | None,
41
+ format_str: str | None,
42
+ settable: bool,
43
+ ) -> ebus_sdk.Property:
44
+ """Construct an ebus_sdk.Property from a profile property and attach it to a node."""
45
+ spec: dict[str, Any] = {
46
+ "id": key,
47
+ "name": name,
48
+ "datatype": datatype,
49
+ }
50
+ if unit is not None:
51
+ spec["unit"] = unit
52
+ if format_str is not None:
53
+ spec["format"] = format_str
54
+ if settable:
55
+ spec["settable"] = True
56
+ return node.add_property_from_dict(spec)
57
+
58
+
59
+ def owned_client(mqttc: object) -> MqttClient | None:
60
+ """Return *mqttc* when it is a client the SDK built and therefore owns.
61
+
62
+ ebus-sdk 0.18 narrowed the injected-transport contract: ``MqttDeviceTransport``
63
+ deliberately omits ``start`` / ``stop``, because those resolve only on the
64
+ concrete client the SDK constructs for an ``mqtt_cfg=`` root — never on one the
65
+ caller injected and still owns. The SDK makes the same distinction internally
66
+ (``Controller.stop`` stops via its owned handle, "never via self.mqttc").
67
+
68
+ Narrowing to the concrete class states that rule in the types instead of
69
+ assuming it: a bring-your-own-transport root returns None here and is left for
70
+ its owner to stop, which is the behaviour the SDK's contract asks for.
71
+ """
72
+ return mqttc if isinstance(mqttc, MqttClient) else None
73
+
74
+
75
+ def will_for_root_id(root_id: str) -> dict[str, str]:
76
+ """The Last Will descriptor for a tree rooted at *root_id*, before any tree exists.
77
+
78
+ A bring-your-own-transport caller has to register the will on their client
79
+ *before connecting*, because it rides the CONNECT packet — which is earlier
80
+ than they can hand that client to an ``Emitter``, and earlier than there is a
81
+ root ``Device`` to ask. So this answers the question from the id alone.
82
+
83
+ It does so by building a throwaway transport-free ``Device`` and returning its
84
+ own ``will()``, rather than formatting the topic here. Construction opens no
85
+ socket, and sourcing the descriptor from the SDK function that the SDK itself
86
+ registers (``connect_broker`` passes ``lwt=self.will()``) is what makes a
87
+ caller-registered will provably identical to an SDK-registered one. Formatting
88
+ the topic locally would be the same string today and a silent divergence the
89
+ day the SDK changes it.
90
+
91
+ The shape is ``Device.will()``'s and therefore ``MqttClient(lwt=...)``'s, so
92
+ it drops straight in where either is expected.
93
+ """
94
+ return ebus_sdk.Device(root_id).will()
95
+
96
+
97
+ def publish_will_now(root: ebus_sdk.Device, *, owned: MqttClient | None) -> bool:
98
+ """Publish the root's will payload (``$state=lost``) as a retained message, now.
99
+
100
+ A Last Will fires only when the broker sees an UNCLEAN disconnect. Every
101
+ orderly teardown path here sends a clean DISCONNECT, and ebus-mqtt-client does
102
+ that deliberately (``MqttClient.stop`` -> ``mqttc.disconnect()``), precisely so
103
+ a normal shutdown is not reported to consumers as a crash. The consequence is
104
+ that a simulator asked to *act* like a producer that died cannot get there by
105
+ letting the will fire: it has to publish the will's own payload itself.
106
+
107
+ Topic and payload come from ``Device.will()``, the same descriptor the SDK
108
+ registers as the LWT, so this cannot drift from what a real will would have
109
+ delivered.
110
+
111
+ QoS deliberately follows the rest of the tree's ``$state`` publishes rather
112
+ than the ``qos=0`` default the will registration uses: a registered will is
113
+ delivered by the broker from its own state, whereas this goes out over a live
114
+ connection that is about to close and has to actually land first.
115
+
116
+ ``set_state`` moves the root's own state to ``LOST`` first, mirroring what
117
+ ``Device.stop()`` does for ``DISCONNECTED``. Publishing a state the device
118
+ object does not itself hold leaves the two disagreeing, and anything that later
119
+ re-announces from that object (``refresh_tree()``, which the SDK asks a
120
+ bring-your-own-transport caller to wire onto their client's on-connect handler)
121
+ republishes ``ready`` straight over the ``lost`` we just sent.
122
+
123
+ It runs before the ownership split rather than after, because that
124
+ re-announce is only *reachable* on the injected path. An owned client's
125
+ connection closes immediately behind this call, so nothing survives to
126
+ re-announce from; a caller-supplied one stays up, reconnects, and does. Both
127
+ paths need the state moved, and the path that needs it most is the one an
128
+ ownership guard would have skipped.
129
+
130
+ ``set_state`` also publishes ``$state`` itself, retained and at the device's
131
+ QoS, through whichever transport the root holds — so on an injected transport
132
+ that one call is the entire job, and nothing follows it here.
133
+
134
+ An earlier revision added an explicit ``transport.publish`` after it, to hand
135
+ the caller a paho ``MQTTMessageInfo`` to wait on. That is removed, because it
136
+ was a byte-identical duplicate of what ``set_state`` had just sent and the
137
+ handle it produced was unusable: waiting on it from the loop thread blocks for
138
+ the full timeout and never completes, which is the same reason
139
+ ``publish_and_flush`` is wrong on this path. Publishing twice to widen a
140
+ window the caller cannot observe is not a service to them.
141
+
142
+ The owned path does still repeat the value, for a different and real reason:
143
+ ``set_state`` goes through the ordinary unflushed path, the socket closes
144
+ immediately behind this function, and only a flushed publish is guaranteed to
145
+ land first.
146
+
147
+ **Caller obligation on an injected transport.** The ``lost`` is *queued* on
148
+ the caller's loop, not flushed, and the emitter cannot flush it for them.
149
+ Tearing the client down in the same synchronous breath as
150
+ ``stop(graceful=False)`` drops it — measured as deterministic against a real
151
+ broker with an ``asyncio_driver``-pumped client: an immediate ``driver.stop()``
152
+ leaves the retained root at ``ready``, while letting the loop turn first
153
+ leaves it at ``lost``. Letting the loop turn is the whole remedy; there is
154
+ nothing to await.
155
+
156
+ Returns whether the ``lost`` is on the wire as far as this function can tell:
157
+ for an owned client, whether the flush completed; for an injected one,
158
+ whether ``set_state`` moved the state and published (False when it was
159
+ already ``LOST``, in which case the wire already carries it).
160
+ """
161
+ moved = root.set_state(DeviceState.LOST)
162
+ if owned is None:
163
+ return moved
164
+ will = root.will()
165
+ return bool(
166
+ owned.publish_and_flush(
167
+ will["topic"], will["payload"], qos=EBUS_HOMIE_MQTT_QOS, retain=True
168
+ )
169
+ )