ebus-panel-sim 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/.github/workflows/ci.yaml +6 -1
  2. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/CHANGELOG.md +10 -0
  3. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/PKG-INFO +1 -1
  4. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/__init__.py +1 -1
  5. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/emitter.py +21 -5
  6. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/_sdk_seam.py +34 -1
  7. ebus_panel_sim-0.3.1/tests/test_teardown.py +73 -0
  8. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/.ebus-spec.json +0 -0
  9. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/.github/CODEOWNERS +0 -0
  10. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/.github/workflows/publish.yml +0 -0
  11. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/.gitignore +0 -0
  12. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/.pre-commit-config.yaml +0 -0
  13. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/.python-version +0 -0
  14. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/AUTHORS +0 -0
  15. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/CONTRIBUTING.md +0 -0
  16. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/DESIGN.md +0 -0
  17. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/DEVELOPER.md +0 -0
  18. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/LICENSE +0 -0
  19. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/README.md +0 -0
  20. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/examples/forty_tab_minimal.yaml +0 -0
  21. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/examples/run_forty_tab_minimal.py +0 -0
  22. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/pyproject.toml +0 -0
  23. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/conventions/__init__.py +0 -0
  24. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/conventions/tab_legs.py +0 -0
  25. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/energy_integrator.py +0 -0
  26. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/exceptions.py +0 -0
  27. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/manifest.py +0 -0
  28. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/manifest_physics.py +0 -0
  29. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/native_devices/__init__.py +0 -0
  30. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/native_devices/bess.py +0 -0
  31. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/native_devices/load_shedding.py +0 -0
  32. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/native_devices/protocol.py +0 -0
  33. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/panel_meter.py +0 -0
  34. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/py.typed +0 -0
  35. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/relay_resolver.py +0 -0
  36. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/snapshot.py +0 -0
  37. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/tick_inputs.py +0 -0
  38. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/__init__.py +0 -0
  39. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/bag_builder.py +0 -0
  40. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/breaker.json +0 -0
  41. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/charge-limit.json +0 -0
  42. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/connection.json +0 -0
  43. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/door.json +0 -0
  44. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/grid.json +0 -0
  45. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/info.json +0 -0
  46. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/load-shed.json +0 -0
  47. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/meter.json +0 -0
  48. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/pcs.json +0 -0
  49. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/power-flows.json +0 -0
  50. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/shed-forecast.json +0 -0
  51. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/shed.json +0 -0
  52. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/soc.json +0 -0
  53. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/status.json +0 -0
  54. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/catalogs/switch.json +0 -0
  55. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/graph_builder.py +0 -0
  56. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/.gitkeep +0 -0
  57. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/bess.yaml +0 -0
  58. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/circuit.yaml +0 -0
  59. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/evse.yaml +0 -0
  60. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/lugs.yaml +0 -0
  61. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/mid.yaml +0 -0
  62. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/panel.yaml +0 -0
  63. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping/pv.yaml +0 -0
  64. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/mapping_loader.py +0 -0
  65. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profile_loader.py +0 -0
  66. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/.gitkeep +0 -0
  67. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/bess.json +0 -0
  68. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/circuit.json +0 -0
  69. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/evse.json +0 -0
  70. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/lugs.json +0 -0
  71. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/mid.json +0 -0
  72. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/panel.json +0 -0
  73. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/pv.json +0 -0
  74. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/span/circuit.json +0 -0
  75. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/span/evse.json +0 -0
  76. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/span/lugs.json +0 -0
  77. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/profiles/span/panel.json +0 -0
  78. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/property_bag.py +0 -0
  79. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/publisher.py +0 -0
  80. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/src/ebus_panel_sim/wire/set_router.py +0 -0
  81. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/__init__.py +0 -0
  82. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/conftest.py +0 -0
  83. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/conventions/__init__.py +0 -0
  84. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/conventions/test_tab_legs.py +0 -0
  85. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_catalog_drift.py +0 -0
  86. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_connection.py +0 -0
  87. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_emitter_public_surface.py +0 -0
  88. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_energy_integrator.py +0 -0
  89. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_exceptions.py +0 -0
  90. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_manifest.py +0 -0
  91. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_manifest_physics.py +0 -0
  92. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_mid_placement.py +0 -0
  93. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_panel_meter.py +0 -0
  94. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_publish_tick.py +0 -0
  95. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_relay_resolver.py +0 -0
  96. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_shed_forecast.py +0 -0
  97. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_tick_inputs.py +0 -0
  98. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_variant.py +0 -0
  99. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/test_wire_units.py +0 -0
  100. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/__init__.py +0 -0
  101. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/test_circuit_energy_frame.py +0 -0
  102. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/test_graph_builder.py +0 -0
  103. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/test_graph_builder_topology.py +0 -0
  104. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/test_profile_mapping_validation.py +0 -0
  105. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/test_property_bag.py +0 -0
  106. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/test_sdk_seam.py +0 -0
  107. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/tests/wire/test_set_router.py +0 -0
  108. {ebus_panel_sim-0.3.0 → ebus_panel_sim-0.3.1}/uv.lock +0 -0
@@ -3,8 +3,13 @@ name: CI
3
3
  on:
4
4
  push:
5
5
  branches: [main]
6
+ # Deliberately unfiltered by base branch. A `branches:` filter here means a PR
7
+ # targeting anything other than `main` gets no checks at all, silently: the PR
8
+ # simply reports "no checks reported" and reads as unvalidated rather than
9
+ # failing. That hides every stacked PR, which is exactly when a second pair of
10
+ # eyes is worth most. Every other repo in the eBus family already runs
11
+ # unfiltered; this one was the outlier.
6
12
  pull_request:
7
- branches: [main]
8
13
 
9
14
  jobs:
10
15
  lint-and-test:
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.1] - 2026-08-07
6
+
7
+ ### Fixed
8
+
9
+ - **`Emitter.stop(graceful=False)` now leaves the tree at `$state=lost`, as it always claimed to.** It documented itself as "leaving the LWT to fire `$state=lost`", which cannot happen: a Last Will fires only on an *unclean* disconnect, and `MqttClient.stop()` sends a clean DISCONNECT deliberately, so that an orderly shutdown is not reported to consumers as a crash. The will was therefore suppressed on every teardown, and an ungraceful stop published nothing at all: verified against a real broker, a consumer joining afterwards read the entire retained tree as `ready`, indefinitely. For a simulator this is the mode's whole purpose, since "act like a producer that died" is exactly what a consumer test needs, so the fix makes the behaviour real rather than deleting the promise. The root's `$state=lost` is now published retained before the connection drops, with topic and payload taken from the SDK's own `Device.will()` descriptor so it cannot drift from what a broker-delivered will would have carried. The difference from a real will is timing and delivery, not content: this lands immediately over the live connection, where a broker-delivered will waits on keepalive expiry, so a consumer exercising the *retained* view sees the same thing either way while one exercising live will delivery does not. `clear_retained` is documented as graceful-only, since a producer that died clears nothing.
10
+
11
+ ### Added
12
+
13
+ - `tests/test_teardown.py`: both teardown modes now have tests, which neither had before. That absence is why a docstring could promise behaviour the code had never performed. Three of the five fail against 0.3.0.
14
+
5
15
  ## [0.3.0] - 2026-08-07
6
16
 
7
17
  First release published to PyPI, as **`ebus-panel-sim`**.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebus-panel-sim
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -69,7 +69,7 @@ from ebus_panel_sim.wire.set_router import SetterHandler, SetterRegistry
69
69
  # via `[tool.hatch.version]`, and publish.yml refuses to release when the git tag
70
70
  # disagrees. Bump it in this one place. Note this is the PACKAGE version and is
71
71
  # distinct from the producer-contract version the docstrings above refer to.
72
- __version__ = "0.3.0"
72
+ __version__ = "0.3.1"
73
73
 
74
74
  __all__ = [
75
75
  "BESSConfig",
@@ -49,7 +49,7 @@ from ebus_panel_sim.snapshot import (
49
49
  EbusPvSnapshot,
50
50
  )
51
51
  from ebus_panel_sim.tick_inputs import TickInputs
52
- from ebus_panel_sim.wire._sdk_seam import owned_client
52
+ from ebus_panel_sim.wire._sdk_seam import owned_client, publish_will_now
53
53
  from ebus_panel_sim.wire.bag_builder import BagBuilder
54
54
  from ebus_panel_sim.wire.graph_builder import build_graph
55
55
  from ebus_panel_sim.wire.mapping_loader import load_mapping_table
@@ -253,11 +253,27 @@ class Emitter:
253
253
  Graceful (default): publish the root's ``$state=disconnected`` then stop
254
254
  the shared client (ebus-sdk's bounded teardown; per Homie's
255
255
  effective-state rule the root going disconnected covers every child).
256
- Non-graceful: stop the client without the disconnected publish, leaving
257
- the LWT to fire ``$state=lost``. ``clear_retained`` additionally clears
258
- every device's retained values + ``$description`` before disconnecting,
259
- for a clean-slate re-run."""
256
+
257
+ Non-graceful: leave the tree looking like a producer that died, by
258
+ publishing the root's ``$state=lost`` retained before dropping the
259
+ connection. This is what a consumer test wants from a simulator, and it
260
+ is emphatically NOT what a bare disconnect gives you: the Last Will fires
261
+ only on an *unclean* disconnect, and every teardown here closes cleanly,
262
+ so relying on the will would leave the whole retained tree claiming
263
+ ``ready`` forever. See ``publish_will_now``, which sources the topic and
264
+ payload from ``Device.will()`` so this cannot drift from the real thing.
265
+
266
+ The difference from a real will is timing and delivery, not content: this
267
+ lands immediately over the live connection, where a broker-delivered will
268
+ waits on keepalive expiry. A consumer exercising the *retained* view sees
269
+ the same thing either way; one exercising live will delivery does not.
270
+
271
+ ``clear_retained`` additionally clears every device's retained values +
272
+ ``$description`` before disconnecting, for a clean-slate re-run. It
273
+ applies to the graceful path only, since a producer that died clears
274
+ nothing."""
260
275
  if not graceful:
276
+ publish_will_now(self._root)
261
277
  client = owned_client(self._root.mqttc)
262
278
  if client is not None:
263
279
  client.stop()
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from typing import Any
12
12
 
13
13
  import ebus_sdk
14
- from ebus_sdk import MqttClient, PropertyDatatype, Unit
14
+ from ebus_sdk import EBUS_HOMIE_MQTT_QOS, MqttClient, PropertyDatatype, Unit
15
15
 
16
16
 
17
17
  def make_property(
@@ -53,3 +53,36 @@ def owned_client(mqttc: object) -> MqttClient | None:
53
53
  its owner to stop, which is the behaviour the SDK's contract asks for.
54
54
  """
55
55
  return mqttc if isinstance(mqttc, MqttClient) else None
56
+
57
+
58
+ def publish_will_now(root: ebus_sdk.Device) -> bool:
59
+ """Publish the root's will payload (``$state=lost``) as a retained message, now.
60
+
61
+ A Last Will fires only when the broker sees an UNCLEAN disconnect. Every
62
+ orderly teardown path here sends a clean DISCONNECT, and ebus-mqtt-client does
63
+ that deliberately (``MqttClient.stop`` -> ``mqttc.disconnect()``), precisely so
64
+ a normal shutdown is not reported to consumers as a crash. The consequence is
65
+ that a simulator asked to *act* like a producer that died cannot get there by
66
+ letting the will fire: it has to publish the will's own payload itself.
67
+
68
+ Topic and payload come from ``Device.will()``, the same descriptor the SDK
69
+ registers as the LWT, so this cannot drift from what a real will would have
70
+ delivered.
71
+
72
+ QoS deliberately follows the rest of the tree's ``$state`` publishes rather
73
+ than the ``qos=0`` default the will registration uses: a registered will is
74
+ delivered by the broker from its own state, whereas this goes out over a live
75
+ connection that is about to close and has to actually land first.
76
+
77
+ Returns True when the broker acknowledged it, False if there is no owned
78
+ client or the publish did not flush in time.
79
+ """
80
+ client = owned_client(root.mqttc)
81
+ if client is None:
82
+ return False
83
+ will = root.will()
84
+ return bool(
85
+ client.publish_and_flush(
86
+ will["topic"], will["payload"], qos=EBUS_HOMIE_MQTT_QOS, retain=True
87
+ )
88
+ )
@@ -0,0 +1,73 @@
1
+ """Teardown semantics: what the retained tree looks like after ``Emitter.stop()``.
2
+
3
+ Both modes were previously untested. The non-graceful path had documented itself as
4
+ "leaving the LWT to fire ``$state=lost``", which cannot happen: a Last Will fires
5
+ only on an *unclean* disconnect, and ``MqttClient.stop()`` sends a clean DISCONNECT
6
+ (deliberately, so an orderly shutdown is not reported as a crash). Verified against
7
+ a real mosquitto broker before this suite existed: a consumer joining after an
8
+ ungraceful teardown read the whole tree as ``ready``.
9
+
10
+ These tests pin the observable outcome, the effective retained ``$state`` a
11
+ late-joining consumer sees, rather than the mechanism.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ebus_panel_sim import Emitter
17
+ from ebus_panel_sim.wire.set_router import SetterRegistry
18
+
19
+ from .conftest import PahoRecorder
20
+ from .test_emitter_public_surface import _manifest
21
+
22
+ ROOT_STATE = "ebus/5/p1/$state"
23
+
24
+
25
+ def _started() -> Emitter:
26
+ emitter = Emitter(_manifest(), SetterRegistry())
27
+ emitter.start()
28
+ return emitter
29
+
30
+
31
+ def test_ungraceful_stop_leaves_the_root_lost(rec: PahoRecorder) -> None:
32
+ """A producer that died must not leave the tree claiming ``ready``."""
33
+ _started().stop(graceful=False)
34
+ assert rec.retained[ROOT_STATE] == "lost"
35
+
36
+
37
+ def test_graceful_stop_leaves_the_root_disconnected(rec: PahoRecorder) -> None:
38
+ """An orderly shutdown is a different signal from a crash, and consumers act
39
+ on the difference: ``disconnected`` is expected, ``lost`` is a fault."""
40
+ _started().stop(graceful=True)
41
+ assert rec.retained[ROOT_STATE] == "disconnected"
42
+
43
+
44
+ def test_the_two_modes_do_not_agree(rec: PahoRecorder) -> None:
45
+ """Guards against a future change collapsing the two teardown modes into one.
46
+
47
+ Note this test passed before the fix too, for the wrong reason: ungraceful left
48
+ the root on ``ready``, which does differ from ``disconnected``. The bug was
49
+ never that the modes agreed, it was that one of them lied. The two tests above
50
+ are what pin the actual values; this one only pins that they stay distinct."""
51
+ _started().stop(graceful=False)
52
+ ungraceful = rec.retained[ROOT_STATE]
53
+ rec.reset()
54
+ _started().stop(graceful=True)
55
+ assert ungraceful != rec.retained[ROOT_STATE]
56
+
57
+
58
+ def test_ungraceful_stop_uses_the_sdk_will_descriptor(rec: PahoRecorder) -> None:
59
+ """The synthesised ``lost`` must be byte-identical to what the broker would
60
+ have delivered from the registered will, or the simulation is a fiction."""
61
+ emitter = _started()
62
+ will = emitter._root.will()
63
+ emitter.stop(graceful=False)
64
+ assert rec.retained[will["topic"]] == will["payload"]
65
+
66
+
67
+ def test_ungraceful_stop_publishes_lost_retained(rec: PahoRecorder) -> None:
68
+ """Retained, or a consumer joining after the death sees nothing at all and
69
+ cannot distinguish a dead producer from one that never existed."""
70
+ _started().stop(graceful=False)
71
+ lost = [(t, d, q, r) for (t, d, q, r) in rec.published if t == ROOT_STATE and d == "lost"]
72
+ assert lost, "no $state=lost publish reached the transport"
73
+ assert all(retain for (_t, _d, _q, retain) in lost)
File without changes
File without changes
File without changes
File without changes
File without changes