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.
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/CHANGELOG.md +25 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/DESIGN.md +2 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/PKG-INFO +53 -5
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/README.md +52 -4
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/__init__.py +8 -1
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/emitter.py +173 -20
- ebus_panel_sim-0.4.0/src/ebus_panel_sim/wire/_sdk_seam.py +169 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/graph_builder.py +76 -28
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/conftest.py +23 -11
- ebus_panel_sim-0.4.0/tests/test_byo_transport.py +381 -0
- ebus_panel_sim-0.4.0/tests/test_documentation.py +173 -0
- ebus_panel_sim-0.3.3/src/ebus_panel_sim/wire/_sdk_seam.py +0 -102
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.ebus-spec.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.github/CODEOWNERS +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.github/workflows/ci.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.github/workflows/publish.yml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.gitignore +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.pre-commit-config.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/.python-version +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/AUTHORS +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/CONTRIBUTING.md +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/DEVELOPER.md +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/LICENSE +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/examples/forty_tab_minimal.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/examples/run_forty_tab_minimal.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/pyproject.toml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/__init__.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/tab_legs.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/energy_integrator.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/exceptions.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest_physics.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/__init__.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/bess.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/load_shedding.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/protocol.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/panel_meter.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/py.typed +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/relay_resolver.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/snapshot.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/tick_inputs.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/__init__.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/bag_builder.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/breaker.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/charge-limit.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/connection.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/door.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/grid.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/info.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/load-shed.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/meter.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/pcs.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/power-flows.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed-forecast.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/soc.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/status.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/switch.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/.gitkeep +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/bess.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/circuit.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/evse.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/lugs.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/mid.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/panel.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/pv.yaml +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping_loader.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profile_loader.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/.gitkeep +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/bess.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/circuit.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/evse.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/lugs.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/mid.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/panel.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/pv.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/circuit.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/evse.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/lugs.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/panel.json +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/property_bag.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/publisher.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/set_router.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/__init__.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/conventions/__init__.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/conventions/test_tab_legs.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_catalog_drift.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_connection.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_emitter_public_surface.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_energy_integrator.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_exceptions.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_manifest.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_manifest_physics.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_mid_placement.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_panel_meter.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_publish_tick.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_relay_resolver.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_shed_forecast.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_teardown.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_tick_inputs.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_variant.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/test_wire_units.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/__init__.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_circuit_energy_frame.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder_topology.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_profile_mapping_validation.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_property_bag.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_sdk_seam.py +0 -0
- {ebus_panel_sim-0.3.3 → ebus_panel_sim-0.4.0}/tests/wire/test_set_router.py +0 -0
- {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
|
+
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
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
92
|
-
#
|
|
93
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
"""
|
|
252
|
-
|
|
253
|
-
Graceful (default): publish the root's ``$state=disconnected
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
+
)
|