ebus-panel-sim 0.3.2__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/CHANGELOG.md +31 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/CONTRIBUTING.md +14 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/DESIGN.md +3 -1
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/DEVELOPER.md +3 -4
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/PKG-INFO +53 -5
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/README.md +52 -4
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/__init__.py +8 -1
- {ebus_panel_sim-0.3.2 → 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.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/graph_builder.py +76 -28
- {ebus_panel_sim-0.3.2 → 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.2 → ebus_panel_sim-0.4.0}/tests/test_teardown.py +22 -0
- ebus_panel_sim-0.3.2/src/ebus_panel_sim/wire/_sdk_seam.py +0 -88
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.ebus-spec.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.github/CODEOWNERS +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.github/workflows/ci.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.github/workflows/publish.yml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.gitignore +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.pre-commit-config.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/.python-version +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/AUTHORS +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/LICENSE +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/examples/forty_tab_minimal.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/examples/run_forty_tab_minimal.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/pyproject.toml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/__init__.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/conventions/tab_legs.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/energy_integrator.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/exceptions.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/manifest_physics.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/__init__.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/bess.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/load_shedding.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/native_devices/protocol.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/panel_meter.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/py.typed +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/relay_resolver.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/snapshot.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/tick_inputs.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/__init__.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/bag_builder.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/breaker.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/charge-limit.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/connection.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/door.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/grid.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/info.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/load-shed.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/meter.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/pcs.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/power-flows.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed-forecast.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/shed.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/soc.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/status.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/catalogs/switch.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/.gitkeep +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/bess.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/circuit.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/evse.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/lugs.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/mid.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/panel.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping/pv.yaml +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/mapping_loader.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profile_loader.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/.gitkeep +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/bess.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/circuit.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/evse.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/lugs.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/mid.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/panel.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/pv.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/circuit.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/evse.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/lugs.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/profiles/span/panel.json +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/property_bag.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/publisher.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/src/ebus_panel_sim/wire/set_router.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/__init__.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/conventions/__init__.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/conventions/test_tab_legs.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_catalog_drift.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_connection.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_emitter_public_surface.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_energy_integrator.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_exceptions.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_manifest.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_manifest_physics.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_mid_placement.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_panel_meter.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_publish_tick.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_relay_resolver.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_shed_forecast.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_tick_inputs.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_variant.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/test_wire_units.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/__init__.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_circuit_energy_frame.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_graph_builder_topology.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_profile_mapping_validation.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_property_bag.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_sdk_seam.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/tests/wire/test_set_router.py +0 -0
- {ebus_panel_sim-0.3.2 → ebus_panel_sim-0.4.0}/uv.lock +0 -0
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-08-08
|
|
6
|
+
|
|
7
|
+
Adds bring-your-own-transport: a producer that already owns its MQTT connection can publish an eBus tree through it. Additive, so nothing existing changes; the minor bump reflects new public API rather than a break.
|
|
8
|
+
|
|
9
|
+
The caller obligations are the part to read before using it. An injected client bypasses the SDK's connect path, so the Last Will and the on-(re)connect republish are yours to wire, and the emitter cannot do either on your behalf. `Notes on the bring-your-own-transport path` below states each one and what it costs to skip it; the README carries the wiring order as a recipe that the test suite executes from the file itself.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Bring-your-own-transport.** `Emitter(..., mqttc=client)` publishes the tree through a client the caller already owns, instead of having one built from `mqtt_cfg`. The two are mutually exclusive and passing both raises. This mirrors ebus-sdk's `Device(mqttc=...)` contract, and the case it serves is a host that cannot afford a second connection — a Home Assistant add-on, whose MQTT integration is `single_config_entry` and which forbids background threads (`ebus-mqtt-client` 0.4.0's `asyncio_driver()` covers pumping the loop). See the README's "Bring your own transport" section for the required wiring order, and the notes below for the behaviour that differs from the `mqtt_cfg` path.
|
|
14
|
+
- **`Emitter.lwt_settings(manifest)`** returns the Last Will to register on a client you intend to inject. A `staticmethod` because it has to be answerable before an `Emitter` exists: the will rides the MQTT CONNECT packet, so it must be on the client before the client connects, which is before that client can be handed to a constructor. The descriptor comes from the SDK's own `Device.will()` — the same function the SDK passes as `lwt=` when it builds a client itself — so a caller-registered will is identical to an SDK-registered one rather than merely similar, and the shape drops into `MqttClient(lwt=...)` unchanged. Without it an injected tree has no will at all, and an unclean death leaves consumers reading a stale retained `ready` indefinitely.
|
|
15
|
+
- **`Emitter.republish_tree()`** re-announces the whole retained tree, for wiring to an injected client's on-connect handler. The SDK registers this itself only for a client it built.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`stop(graceful=False)` left the root Device holding `ready` on an injected transport.** 0.3.3 moved the state before publishing but sourced that call through `owned_client()`, which returns None for a caller-supplied client, so the whole ungraceful teardown was a no-op there: object on `ready`, nothing on the wire. Latent in 0.3.3, because `mqttc=` did not exist to reach it; reachable the moment this release adds it. `set_state` now runs before the ownership split. This is the one entry here describing a defect in released code, and it could not be triggered by a 0.3.3 user.
|
|
20
|
+
|
|
21
|
+
### Notes on the bring-your-own-transport path
|
|
22
|
+
|
|
23
|
+
Behaviour that is specific to `mqttc=` and easy to get wrong. None of it is a change to the `mqtt_cfg` path, whose wire output is byte-identical to 0.3.3.
|
|
24
|
+
|
|
25
|
+
- **`start()` does not wait, and there is nothing to wait for.** The SDK never starts a client it did not build, so polling `is_connected()` would stall the very event loop such a client is likely driven on without changing the outcome. Values are retained; the tree goes out on the first `publish_tick`.
|
|
26
|
+
- **`stop()` never stops your client, on either path.** Ownership decides, not type — an injected client can itself be an `MqttClient`. It does still go mute: ebus-sdk's `Device.stop()` clears the root's transport reference regardless of ownership, so `republish_tree()` publishes nothing afterwards and an on-connect hook wired to it becomes a silent no-op on a still-live client. Build a new `Emitter` to resume.
|
|
27
|
+
- **Nothing re-announces your tree unless you wire it.** The SDK registers its on-(re)connect republish inside `connect_broker()`, below an `if self.mqttc` early return that an injected client always takes. Measured against a real broker with the retained store wiped: an injected tree came back 5 topics of 56, every `$description` missing, where an owned one came back all 56. `republish_tree()` is the remedy; the README recipe wires it.
|
|
28
|
+
- **Let your event loop turn before closing the client.** `stop(graceful=False)` queues the `lost` rather than flushing it — `wait_for_publish` would block the very thread that has to run `loop_write` for a client pumped by `asyncio_driver`. Closing the client in the same synchronous breath drops the message and leaves the retained tree on `ready`: deterministic, not a race. There is nothing to await; one turn of the loop is the whole remedy.
|
|
29
|
+
|
|
30
|
+
## [0.3.3] - 2026-08-07
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **`stop(graceful=False)` published `$state=lost` but left the root Device still holding `ready`.** The wire and the object disagreed, so anything re-announcing from that object republished `ready` straight over the `lost`. It now goes through the SDK's public `Device.set_state(DeviceState.LOST)` first, mirroring what `Device.stop()` already does for `disconnected`. The flushed publish that follows is deliberately the same retained value a second time: `set_state` uses the ordinary unflushed path, and on the owned path the connection closes immediately behind the call, so only a flushed publish is guaranteed to land. Costs one message at teardown. The observable retained outcome is unchanged (confirmed against a real broker: `p1=lost`); what changes is that it now survives a subsequent re-announce. Two tests pin it, both failing against 0.3.2. Found by [@cayossarian](https://github.com/cayossarian) while building on this code in [#17](https://github.com/electrification-bus/distribution-enclosure-simulator/pull/17).
|
|
35
|
+
|
|
5
36
|
## [0.3.2] - 2026-08-07
|
|
6
37
|
|
|
7
38
|
Metadata-only. No source changes, so the published tree and the public API are identical to 0.3.1; this release exists to get the packaging metadata below onto PyPI, where it only takes effect on a publish.
|
|
@@ -39,6 +39,20 @@ Pull requests are welcome.
|
|
|
39
39
|
- **Keep comments to a minimum.** The project style is self-explanatory code, with comments reserved for the non-obvious *why* (a spec quirk, a Homie nuance, a SPAN-variant deviation). Don't add comments that just restate the code.
|
|
40
40
|
- One commit per logical change is fine; we don't require squash or any particular branch naming.
|
|
41
41
|
|
|
42
|
+
## What "done" means
|
|
43
|
+
|
|
44
|
+
This package is published to PyPI, and a PyPI upload can never be replaced. So an increment that lands behaviour and leaves its documentation for later is not a smaller version of the change: it is a release whose docs contradict its code, and the correction costs another release. Ship the whole thing.
|
|
45
|
+
|
|
46
|
+
A change is done when, in the same PR:
|
|
47
|
+
|
|
48
|
+
- **Prose it invalidates is fixed.** If a change makes a sentence in the README, `DESIGN.md`, `DEVELOPER.md`, or a docstring untrue, that sentence is part of the change. A new option whose README still describes the old single path is not finished.
|
|
49
|
+
- **Types its signatures name are reachable.** A public parameter annotated with a type a caller cannot import from `ebus_panel_sim` forces them to depend on `ebus_sdk` directly, which is what the SDK seam exists to spare them.
|
|
50
|
+
- **It carries its `CHANGELOG.md` entry**, under `[Unreleased]` or a version heading.
|
|
51
|
+
- **New behaviour has a test that fails without it.** Say so in the PR, and say which. A test that passes against the previous commit is not evidence.
|
|
52
|
+
- **Caller obligations are written down where the caller will look.** If correct use requires the caller to do something (wire a callback, set something before connecting, let an event loop turn), a `#` comment in our source does not reach them.
|
|
53
|
+
|
|
54
|
+
Splitting a change is fine when the parts are genuinely independent, or when a decision is needed that only a maintainer can make. It is not fine as a way to defer the half that needs no decision. If you are unsure which you have, open the PR with the whole thing and let the review split it.
|
|
55
|
+
|
|
42
56
|
## Local development
|
|
43
57
|
|
|
44
58
|
Python >= 3.11 (developed and CI-tested on 3.11 and 3.14), managed with [uv](https://docs.astral.sh/uv/). See [DEVELOPER.md](DEVELOPER.md) for the full guide.
|
|
@@ -17,7 +17,9 @@ The producer builds a `DeviceManifest` (identity plus physics keys per device) a
|
|
|
17
17
|
|
|
18
18
|
The enclosure is a Homie root device (`energy.ebus.device.distribution-enclosure`); every circuit, lugs pair, and integrated DER (BESS, PV, EVSE, MID) is a separate child Homie device with `root` and `parent` back-references to the enclosure. Each device's properties are grouped into capability-typed nodes (`info`, `meter`, `switch`, `breaker`, `load-shed`, `pcs`, `connection`, `status`, `door`, `soc`, `shed`, `shed-forecast`, `grid`, `config`, `power-flows`), whose node `$type` is `energy.ebus.capability.<capability>`. A child device therefore publishes under its own topic root, for example `ebus/5/<circuit-id>/switch/relay` and `ebus/5/<bess-id>-mid/grid/islanding-state`.
|
|
19
19
|
|
|
20
|
-
Placement is declarative. Each `wire/mapping/*.yaml` descriptor says whether its device class is the `root-device` or a `child-of-parent`; `graph_builder` walks the manifest, mappings, and profiles to build the SDK device graph, and the SDK's `Device` owns the `$state` cascade: `graph_builder` wraps each device's node/property build in a `state_transition()` that coalesces the description republish into a single `init` then `ready` cycle, while `Emitter.start()`/`stop()` drive connect and disconnect. A graceful `stop()` publishes only the root's `$state=disconnected` (by Homie's effective-state rule that covers every child);
|
|
20
|
+
Placement is declarative. Each `wire/mapping/*.yaml` descriptor says whether its device class is the `root-device` or a `child-of-parent`; `graph_builder` walks the manifest, mappings, and profiles to build the SDK device graph, and the SDK's `Device` owns the `$state` cascade: `graph_builder` wraps each device's node/property build in a `state_transition()` that coalesces the description republish into a single `init` then `ready` cycle, while `Emitter.start()`/`stop()` drive connect and disconnect. A graceful `stop()` publishes only the root's `$state=disconnected` (by Homie's effective-state rule that covers every child); `stop(graceful=False)` publishes the root's `$state=lost` itself, because the registered LWT cannot deliver it (a will fires only on an *unclean* disconnect, and every teardown path here closes cleanly, deliberately, so an orderly shutdown is not reported as a crash); retained topics are cleared only when `stop(clear_retained=True)` is passed, which is graceful-only, since a producer that died clears nothing. The vendored `wire/profiles/*.json` are the schema (capabilities, properties, datatypes, units, `$format`, settability); `bag_builder` maps each profile-declared property to a snapshot accessor and fails loud at construction if any declared property has no source.
|
|
21
|
+
|
|
22
|
+
That description assumes the emitter owns the connection. With an injected transport (`Emitter(mqttc=...)`) the same teardown still moves the root to `$state=lost` and publishes it, but three things move to the caller, because the emitter never starts or stops a client it did not build. The LWT is registered by the caller before they connect (`Emitter.lwt_settings(manifest)` answers it without an instance, since the will rides the CONNECT packet); the on-(re)connect whole-tree republish is wired by the caller (`Emitter.republish_tree`), the SDK registering its own only inside `connect_broker()`, below the `if self.mqttc` early return an injected client always takes; and the ungraceful `lost` is *queued* on the caller's loop rather than flushed, since flushing would block the thread running `loop_write`, so the caller must let the loop turn before closing the client. Absent the first, the tree has no will and an unclean death leaves consumers on a stale retained `ready`; absent the second, a broker that loses its retained store never gets the tree back.
|
|
21
23
|
|
|
22
24
|
## Native devices
|
|
23
25
|
|
|
@@ -131,13 +131,12 @@ distribution-enclosure-simulator/
|
|
|
131
131
|
bag_builder.py # Snapshot -> PropertyBag translator
|
|
132
132
|
property_bag.py # Per-tick property values + diff cache
|
|
133
133
|
publisher.py # Per-tick diff/publish loop
|
|
134
|
-
lifecycle.py # $state, $description, /set subscription, LWT
|
|
135
134
|
set_router.py # Setter registry and /set dispatch
|
|
136
|
-
|
|
137
|
-
_sdk_seam.py # Internal seam over ebus_sdk.property
|
|
135
|
+
_sdk_seam.py # Internal seam over ebus_sdk (property build, owned-client narrowing, will publish)
|
|
138
136
|
profiles/ # Vendored Homie 5 device profiles (JSON), per device type
|
|
139
137
|
mapping/ # Vendored mapping descriptors (YAML), per device type
|
|
140
|
-
|
|
138
|
+
catalogs/ # Vendored spec capability catalogs (JSON); the wire type contract
|
|
139
|
+
tests/ # pytest suite; paho is patched, so no broker and no socket
|
|
141
140
|
conftest.py
|
|
142
141
|
conventions/ # convention tests
|
|
143
142
|
wire/ # wire-layer tests
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebus-panel-sim
|
|
3
|
-
Version: 0.
|
|
3
|
+
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
|