From e6d7fd6659b46bcd3c03cdf9f1dbf2c7a99369a3 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 19:47:33 +0200 Subject: [PATCH 1/3] fix: cancel pending work on retained mode changes --- .../packages/smart_plant_profile_mqtt.yaml | 29 +- tests/display_automation.py | 66 +++- tests/test_maintenance_storage.py | 284 ++++++++++++++++++ 3 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 tests/test_maintenance_storage.py diff --git a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml index 51db6ce..687025b 100644 --- a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml +++ b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml @@ -105,6 +105,8 @@ ota: # the new firmware performs a normal measurement/display/sleep cycle. on_end: then: + # Do not let the old deadline refresh the panel during the OTA flush. + - script.stop: maintenance_watchdog - lambda: |- id(maintenance_requested) = false; id(maintenance_active) = false; @@ -145,6 +147,8 @@ switch: retain: true turn_on_action: - lambda: 'id(maintenance_requested) = true;' + # Renew the window before the new sample or display refresh can yield. + - script.stop: maintenance_watchdog - if: condition: lambda: 'return id(boot_sequence_complete);' @@ -154,9 +158,17 @@ switch: - lambda: 'id(maintenance_requested) = false;' - if: condition: - lambda: 'return id(boot_sequence_complete) && id(maintenance_active);' + lambda: 'return id(boot_sequence_complete);' then: - - script.execute: decide_sleep + # OFF also cancels admission before maintenance_active becomes true. + - script.stop: prepare_cycle + - if: + condition: + lambda: 'return id(maintenance_active);' + then: + - script.execute: decide_sleep + else: + - script.execute: prepare_cycle - platform: template id: storage_control @@ -246,6 +258,15 @@ script: - id: prepare_cycle mode: restart then: + # Restart also cancels asynchronous children of the previous command. + - script.stop: decide_sleep + - script.stop: enter_storage + - script.stop: exit_storage + - script.stop: exit_maintenance + - script.stop: acquire_normal + - script.stop: render_measurement_page + - script.stop: sample_battery + - script.execute: prepare_display_refresh - if: condition: lambda: 'return id(maintenance_requested);' @@ -281,7 +302,7 @@ script: condition: lambda: |- return id(batpercent).has_state() && - !isnan(id(batpercent).state) && + std::isfinite(id(batpercent).state) && id(batpercent).state >= ${ota_min_battery}; then: - lambda: |- @@ -334,7 +355,7 @@ script: condition: lambda: |- return id(batpercent).has_state() && - !isnan(id(batpercent).state); + std::isfinite(id(batpercent).state); then: - text_sensor.template.publish: id: maintenance_status diff --git a/tests/display_automation.py b/tests/display_automation.py index 38e124c..41d9f6f 100644 --- a/tests/display_automation.py +++ b/tests/display_automation.py @@ -1,6 +1,7 @@ """Offline executor for the display scripts, read directly from package YAML. -Only the action primitives used by these scripts are supported. C++ lambdas +Only the action primitives used by these scripts are supported. Control tests +record publications and sensor updates; they never contact a broker or device. C++ lambdas run unchanged in a native library with fake globals, time and GPIO. Script restart/stop cancels the caller's continuation and waits, not called scripts, matching ESPHome 2026.9.0 script.h / automation.h / base_automation.h. @@ -27,7 +28,7 @@ class PackageLoader(yaml.SafeLoader): class DisplayProgram: - def __init__(self, root, profile=None, standalone=False): + def __init__(self, root, profile=None, standalone=False, control=False): if standalone: core = yaml.load((Path(root) / "docs/source/files/configuration.yaml").read_text(), PackageLoader) transport = {"script": []} @@ -37,8 +38,15 @@ def __init__(self, root, profile=None, standalone=False): transport = yaml.load( (packages / f"smart_plant_profile_{profile}.yaml").read_text(), PackageLoader ) - self.substitutions = core["substitutions"] + self.substitutions = dict(core["substitutions"], **transport.get("substitutions", {})) available = {s["id"]: s for p in (core, transport) for s in p["script"]} + if control: + for switch in transport.get("switch", []): + for direction in ("on", "off"): + available[f'{switch["id"]}_{direction}'] = { + "then": switch[f"turn_{direction}_action"]} + available["ota_end"] = {"then": transport["ota"][0]["on_end"]["then"]} + available["boot"] = {"then": transport["esphome"]["on_boot"][-1]["then"]} # Extract the actual refresh boundary at every caller, including the # maintenance path. Surrounding plant/page decisions are irrelevant to # cancellation; keep the real prepare/update/settle/wait action order. @@ -61,6 +69,10 @@ def refreshes(node): pending = ["settle_display", "enter_deep_sleep", "sleep_after_display"] if standalone: pending.append("consider_deep_sleep") + if control: + pending += ["prepare_cycle", "decide_sleep", "ota_end", "boot", + "maintenance_control_on", "maintenance_control_off", + "storage_control_on", "storage_control_off"] for sequence in self.refresh_sequences: for action in sequence: if "script.execute" in action: @@ -77,6 +89,8 @@ def visit(node): continue # Outputs are recorded, not sent or formatted. if key == "lambda": snippets.add(value) + elif key == "display.page.show" and "return" in value: + snippets.add(value) elif key in ("script.execute", "script.stop", "script.wait", "script.is_running"): pending.append(value) else: @@ -88,25 +102,39 @@ def visit(node): self.scripts[name] = available[name] visit(available[name]["then"]) - fields = [(g["id"], g["type"], g["initial_value"]) for g in core["globals"]] + fields = [(g["id"], g["type"], g["initial_value"]) + for p in (core, transport) for g in p.get("globals", [])] fields += [("busy", "int", "0"), ("reset_low_count", "int", "0"), ("reset_high_count", "int", "0"), ("now", "uint32_t", "0")] if standalone: fields.append(("esptime", "Clock", "0")) + if control: + fields += [("batpercent", "Sensor", None), ("temp", "Sensor", None), + ("hum", "Sensor", None), ("light", "Sensor", None), + ("soil", "Sensor", None), ("soil_voltage", "Sensor", None), + ("esptime", "Clock", None), ("selected_page", "int", "0"), + ("mqtt_connected", "bool", "true")] + fields += [(page["id"], "int", str(index)) + for index, page in enumerate(core["display"][0]["pages"], 1)] + types = {"bool": ctypes.c_bool, "int": ctypes.c_int, "uint32_t": ctypes.c_uint32} + + class Sensor(ctypes.Structure): + _fields_ = [("state", ctypes.c_float), ("available", ctypes.c_bool)] class Clock(ctypes.Structure): - _fields_ = [("valid", ctypes.c_bool)] - types = {"bool": ctypes.c_bool, "int": ctypes.c_int, "uint32_t": ctypes.c_uint32, "Clock": Clock} + _fields_ = [("hour", ctypes.c_int), ("minute", ctypes.c_int), ("valid", ctypes.c_bool)] + types.update(Sensor=Sensor, Clock=Clock) class State(ctypes.Structure): _fields_ = [(name, types[kind]) for name, kind, _ in fields] self.State = State - self.initial = {name: Clock(False) if kind == "Clock" else value == "true" if kind == "bool" else int(value) + self.initial = {name: (Sensor(80, True) if kind == "Sensor" else Clock(12, 0, True)) if value is None + else Clock(0, 0, False) if kind == "Clock" else value == "true" if kind == "bool" else int(value) for name, kind, value in fields} - source = ["#include \n#include \n", - "struct Now { bool valid; bool is_valid() { return valid; } };" - "struct Clock { bool valid; Now now() { return {valid}; } };", + source = ["#include \n#include \n#include \n", + "using std::isnan; struct Sensor { float state; bool available; bool has_state() const { return available; } };", + "struct Clock { int hour; int minute; bool valid; bool is_valid() const { return valid; } Clock now() const { return *this; } };", "struct State {" + "".join(f"{kind} {name};" for name, kind, _ in fields) + "};", "constexpr int HIGH=1, LOW=0, OUTPUT=1;", "#define id(name) state->name", @@ -180,6 +208,8 @@ def condition(self, condition): return value in self.tasks if key == "not": return not self.condition(value) + if key == "mqtt.connected": + return self.state.mqtt_connected raise ValueError(f"Unsupported condition: {key}") def stop(self, name): @@ -230,18 +260,22 @@ def actions(self, actions, task): yield ("wait_until", value["condition"], self.state.now + self.program.duration(value["timeout"])) elif key == "component.update": - if value != "my_display": - raise ValueError(f"Unsupported component: {value}") - if self.state.reset_low_count != self.state.reset_high_count: + if value == "my_display" and self.state.reset_low_count != self.state.reset_high_count: raise AssertionError("Display SPI update occurred while RST was LOW") self.events.append((self.state.now, key, value)) + elif key == "display.page.show": + self.state.selected_page = (int(self.program.functions[value](ctypes.byref(self.state))) + if value in self.program.functions else getattr(self.state, value)) + self.events.append((self.state.now, key, self.state.selected_page)) + elif key == "repeat": + for _ in range(value["count"]): + yield from self.actions(value["then"], task) elif key == "deep_sleep.enter": duration = value.get("sleep_duration", "1h") if isinstance(value, dict) else "1h" self.sleep = (self.state.now, self.program.duration(duration)) self.events.append((self.state.now, key, duration)) - elif key in ("deep_sleep.allow", "deep_sleep.prevent", "switch.turn_off", - "logger.log", "mqtt.publish", "text_sensor.template.publish", - "safe_mode.mark_successful"): + elif key in ("deep_sleep.allow", "deep_sleep.prevent", "switch.turn_off", "switch.turn_on", + "safe_mode.mark_successful", "switch.template.publish", "logger.log", "mqtt.publish", "text_sensor.template.publish"): self.events.append((self.state.now, key, value)) else: raise ValueError(f"Unsupported action: {key}") diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py new file mode 100644 index 0000000..964170c --- /dev/null +++ b/tests/test_maintenance_storage.py @@ -0,0 +1,284 @@ +"""Run the real retained-control YAML and native C++ policy lambdas offline.""" +from pathlib import Path +import math +import unittest + +import yaml + +from display_automation import DisplayProgram, DisplayRuntime, PackageLoader + + +ROOT = Path(__file__).resolve().parents[1] + + +class MaintenanceStorageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.program = DisplayProgram(ROOT, "mqtt", control=True) + + @classmethod + def tearDownClass(cls): + cls.program.close() + + def runtime(self, healthy_display=True): + runtime = DisplayRuntime(self.program) + def busy(ms): + updates = [t for t, key, value in runtime.events + if key == "component.update" and value == "my_display"] + return int(bool(updates) and healthy_display and 50 <= ms - updates[-1] < 350) + runtime.busy = busy + runtime.state.boot_sequence_complete = True + return runtime + + def publications(self, runtime, entity): + return [(t, value["state"]) for t, key, value in runtime.events + if key == "text_sensor.template.publish" and value["id"] == entity] + + def updates(self, runtime): + return [value for _, key, value in runtime.events if key == "component.update"] + + def test_admission_finite_threshold_and_missing_battery(self): + for value, available, expected in [ + (49.999, True, "REJECTED_LOW_BATTERY"), (50, True, "ON"), + (80, True, "ON"), (80, False, "BATTERY_UNAVAILABLE"), + (math.nan, True, "BATTERY_UNAVAILABLE"), (math.inf, True, "BATTERY_UNAVAILABLE"), + (-math.inf, True, "BATTERY_UNAVAILABLE"), + ]: + with self.subTest(value=value, available=available): + runtime = self.runtime() + runtime.state.batpercent.state = value + runtime.state.batpercent.available = available + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(6000) + states = [state for _, state in self.publications(runtime, "maintenance_status")] + self.assertIn(expected, states) + self.assertEqual(runtime.state.maintenance_active, expected == "ON") + if expected != "ON": + self.assertNotIn("ON", states) + self.assertFalse(runtime.state.maintenance_requested) + self.assertIsNotNone(runtime.sleep) + + def test_both_requests_choose_maintenance_before_storage(self): + runtime = self.runtime() + runtime.state.storage_requested = True + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + self.assertTrue(runtime.state.maintenance_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_maintenance) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertFalse(runtime.state.storage_active) + self.assertIsNone(runtime.sleep) + + def test_daily_storage_boot_only_samples_battery_and_preserves_long_sleep(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.execute("boot") + runtime.run_until(5000) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertEqual(runtime.state.selected_page, runtime.state.page_storage) + self.assertEqual(runtime.sleep[1], 86400000) + self.assertFalse(runtime.state.storage_display_refresh_pending) + + def test_storage_persists_when_broker_is_offline(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.mqtt_connected = False + runtime.execute("boot") + runtime.run_until(36000) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_rejected_maintenance_falls_back_to_storage_and_keeps_rejection(self): + runtime = self.runtime() + runtime.state.batpercent.state = 40 + runtime.state.storage_requested = True + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(5000) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_storage) + self.assertIn((260, "REJECTED_LOW_BATTERY"), self.publications(runtime, "maintenance_status")) + self.assertNotIn("OFF", [state for _, state in self.publications(runtime, "maintenance_status")]) + + def test_busylow_without_observed_assertion_never_acknowledges_ota(self): + runtime = self.runtime(healthy_display=False) + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(6000) + states = [state for _, state in self.publications(runtime, "maintenance_status")] + self.assertIn("DISPLAY_REFRESH_FAILED", states) + self.assertNotIn("ON", states) + self.assertIsNotNone(runtime.sleep) + + def test_ack_follows_real_busy_cycle(self): + runtime = self.runtime() + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + published = self.publications(runtime, "maintenance_status") + self.assertEqual([state for _, state in published], ["ON"]) + refresh_at = next(t for t, key, value in runtime.events + if key == "component.update" and value == "my_display") + self.assertGreaterEqual(published[0][0], refresh_at + 350 + 500) + + def test_watchdog_and_manual_off_clear_retained_command_and_restore_readings(self): + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "2s" + try: + for manual in (False, True): + with self.subTest(manual=manual): + runtime = self.runtime() + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + if manual: + runtime.execute("maintenance_control_off") + runtime.run_until(24000) + self.assertFalse(runtime.state.maintenance_active) + self.assertFalse(runtime.state.maintenance_requested) + self.assertIsNotNone(runtime.sleep) + self.assertNotIn("maintenance_watchdog", runtime.tasks) + self.assertIn("aht20_sensor", self.updates(runtime)) + self.assertEqual(runtime.state.selected_page, runtime.state.page1) + commands = [value for _, key, value in runtime.events if key == "mqtt.publish"] + self.assertTrue(any(v.get("payload") == "OFF" and "/cmd/maintenance" in v["topic"] + and v["qos"] == 1 and v["retain"] for v in commands)) + finally: + self.program.substitutions["maintenance_timeout"] = old + + def test_repeated_on_cancels_old_deadline_before_new_refresh(self): + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "2s" + try: + runtime = self.runtime() + runtime.execute("maintenance_control_on") + runtime.run_until(2900) + # First watchdog expires after the first settled refresh plus 2s. + # Renew while that deadline is close and before the new settle ends. + runtime.execute("maintenance_control_on") + runtime.run_until(4600) + self.assertTrue(runtime.state.maintenance_active) + self.assertTrue(runtime.state.maintenance_requested) + self.assertNotIn("exit_maintenance", [v for _, k, v in runtime.events if k == "script.execute"]) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], ["ON", "ON"]) + finally: + self.program.substitutions["maintenance_timeout"] = old + + def test_successful_ota_clears_one_shot_without_new_display_refresh(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.maintenance_active = True + runtime.state.maintenance_requested = True + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "1s" + runtime.execute("maintenance_watchdog") + self.program.substitutions["maintenance_timeout"] = old + runtime.execute("ota_end") + runtime.run_until(2100) + self.assertFalse(runtime.state.maintenance_requested) + self.assertFalse(runtime.state.maintenance_active) + self.assertTrue(runtime.state.storage_display_refresh_pending) + self.assertNotIn("my_display", self.updates(runtime)) + self.assertIn("OFF", [s for _, s in self.publications(runtime, "maintenance_status")]) + self.assertNotIn("exit_maintenance", [v for _, k, v in runtime.events if k == "script.execute"]) + + def test_storage_off_reacquires_plant_sensors_before_normal_sleep(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.storage_requested = True + runtime.execute("storage_control_off") + runtime.run_until(24000) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page1) + self.assertIn("aht20_sensor", self.updates(runtime)) + self.assertIn("veml7700_sensor", self.updates(runtime)) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_storage_request_during_maintenance_waits_for_manual_exit(self): + runtime = self.runtime() + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + before = len(self.updates(runtime)) + runtime.execute("storage_control_on") + runtime.run_until(2100) + self.assertEqual(len(self.updates(runtime)), before) + self.assertTrue(runtime.state.maintenance_active) + runtime.execute("maintenance_control_off") + runtime.run_until(5000) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_storage) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_critical_protection_overrides_storage_page_and_duration(self): + runtime = self.runtime() + runtime.state.batpercent.state = 15 + runtime.state.storage_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(5000) + self.assertTrue(runtime.state.low_batt_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_low_batt) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_off_cancels_pending_sample_time_wait_and_maintenance_refresh(self): + for at_ms, valid_time in [(100, True), (500, False), (500, True)]: + with self.subTest(at_ms=at_ms, valid_time=valid_time): + runtime = self.runtime() + runtime.state.esptime.valid = valid_time + runtime.execute("maintenance_control_on") + runtime.run_until(at_ms) + runtime.execute("maintenance_control_off") + runtime.state.esptime.valid = True + runtime.run_until(26000) + self.assertFalse(runtime.state.maintenance_requested) + self.assertFalse(runtime.state.maintenance_active) + self.assertNotIn("ON", [s for _, s in self.publications(runtime, "maintenance_status")]) + self.assertIn("aht20_sensor", self.updates(runtime)) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_latest_storage_command_cancels_pending_refresh_and_acquisition(self): + for final_on in (False, True): + with self.subTest(final_on=final_on): + runtime = self.runtime() + runtime.execute("storage_control_on") + runtime.run_until(450) + runtime.execute("storage_control_off") + runtime.run_until(700) + if final_on: + runtime.execute("storage_control_on") + runtime.run_until(26000) + self.assertEqual(runtime.state.storage_active, final_on) + self.assertEqual(runtime.state.selected_page, + runtime.state.page_storage if final_on else runtime.state.page1) + self.assertEqual(runtime.sleep[1], 86400000 if final_on else 3600000) + if final_on: + self.assertNotIn("acquire_normal", runtime.tasks) + + def test_topic_contract_persistence_and_transport_separation(self): + packages = ROOT / "examples/multi-device/packages" + mqtt = yaml.load((packages / "smart_plant_profile_mqtt.yaml").read_text(), PackageLoader) + core = yaml.load((packages / "smart_plant_core.yaml").read_text(), PackageLoader) + for switch in mqtt["switch"]: + self.assertEqual(switch["subscribe_qos"], 1) + self.assertEqual(switch["qos"], 1) + self.assertTrue(switch["retain"] and switch["command_retain"]) + self.assertIn("/cmd/", switch["command_topic"]) + for sensor in mqtt["text_sensor"]: + self.assertEqual(sensor["qos"], 1) + self.assertTrue(sensor["retain"]) + self.assertIn("/status/", sensor["state_topic"]) + for package, name in ((core, "storage_active"), (mqtt, "storage_display_refresh_pending")): + self.assertTrue(next(g for g in package["globals"] if g["id"] == name)["restore_value"]) + self.assertNotIn("mqtt", core) + self.assertNotIn("mqtt.publish", (packages / "smart_plant_profile_api.yaml").read_text()) + names = [s["name"] for s in mqtt["switch"] + mqtt["text_sensor"]] + self.assertEqual(len(names), len(set(names))) + + +if __name__ == "__main__": + unittest.main() From 0d5814a8a48834a6d849fd67aaed94c0f96eb74b Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 20:06:16 +0200 Subject: [PATCH 2/3] fix: ignore echoed maintenance OFF after cycle completion --- .../packages/smart_plant_profile_mqtt.yaml | 11 +++-- tests/test_maintenance_storage.py | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml index 687025b..9adf803 100644 --- a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml +++ b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml @@ -160,15 +160,20 @@ switch: condition: lambda: 'return id(boot_sequence_complete);' then: - # OFF also cancels admission before maintenance_active becomes true. - - script.stop: prepare_cycle - if: condition: lambda: 'return id(maintenance_active);' then: + - script.stop: prepare_cycle - script.execute: decide_sleep else: - - script.execute: prepare_cycle + # Cancel pending admission. Ignore OFF echoes after rejection + # or exit, which must not start a second acquisition cycle. + - if: + condition: + script.is_running: prepare_cycle + then: + - script.execute: prepare_cycle - platform: template id: storage_control diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py index 964170c..6794bf0 100644 --- a/tests/test_maintenance_storage.py +++ b/tests/test_maintenance_storage.py @@ -259,6 +259,50 @@ def test_latest_storage_command_cancels_pending_refresh_and_acquisition(self): if final_on: self.assertNotIn("acquire_normal", runtime.tasks) + def test_rejection_off_echo_keeps_result_and_does_not_start_another_cycle(self): + for storage in (False, True): + with self.subTest(storage=storage): + runtime = self.runtime() + runtime.state.batpercent.state = 40 + runtime.state.storage_requested = storage + runtime.execute("maintenance_control_on") + runtime.run_until(500) + # A broker forwards our retained cmd/maintenance OFF back to + # the subscribed switch. TemplateSwitch runs OFF even if OFF. + runtime.execute("maintenance_control_off") + runtime.run_until(24000) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], + ["REJECTED_LOW_BATTERY"]) + self.assertEqual(runtime.state.storage_active, storage) + self.assertIsNotNone(runtime.sleep) + + def test_exit_off_echo_preserves_one_full_acquisition(self): + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "2s" + try: + for manual in (False, True): + with self.subTest(manual=manual): + runtime = self.runtime() + runtime.execute("maintenance_control_on") + runtime.run_until(2000) + if manual: + runtime.execute("maintenance_control_off") + runtime.run_until(2100) + else: + runtime.run_until(3300) + self.assertFalse(runtime.state.maintenance_active) + runtime.execute("maintenance_control_off") + runtime.run_until(26000) + started = [v for _, k, v in runtime.events if k == "script.execute"] + self.assertEqual(started.count("acquire_normal"), 1) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], + ["ON", "OFF", "OFF"]) + self.assertEqual(runtime.sleep[1], 3600000) + finally: + self.program.substitutions["maintenance_timeout"] = old + def test_topic_contract_persistence_and_transport_separation(self): packages = ROOT / "examples/multi-device/packages" mqtt = yaml.load((packages / "smart_plant_profile_mqtt.yaml").read_text(), PackageLoader) From 7e1c9da8e22c0471627954a9500373bc55d646d6 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 20:07:43 +0200 Subject: [PATCH 3/3] fix: ignore duplicate storage commands during mode changes --- .../packages/smart_plant_profile_mqtt.yaml | 22 ++++++--- tests/test_maintenance_storage.py | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml index 9adf803..b4825d8 100644 --- a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml +++ b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml @@ -185,20 +185,30 @@ switch: subscribe_qos: 1 qos: 1 retain: true + # Storage has no renewable window. Equal desired-state commands, including + # our retained OFF echo, must not restart pending or completed cycle work. turn_on_action: - - lambda: 'id(storage_requested) = true;' - if: condition: - lambda: 'return id(boot_sequence_complete) && !id(maintenance_active);' + lambda: 'return !id(storage_requested);' then: - - script.execute: prepare_cycle + - lambda: 'id(storage_requested) = true;' + - if: + condition: + lambda: 'return id(boot_sequence_complete) && !id(maintenance_active);' + then: + - script.execute: prepare_cycle turn_off_action: - - lambda: 'id(storage_requested) = false;' - if: condition: - lambda: 'return id(boot_sequence_complete) && !id(maintenance_active);' + lambda: 'return id(storage_requested);' then: - - script.execute: prepare_cycle + - lambda: 'id(storage_requested) = false;' + - if: + condition: + lambda: 'return id(boot_sequence_complete) && !id(maintenance_active);' + then: + - script.execute: prepare_cycle text_sensor: # Publish the actual state-machine result through a native entity so Home diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py index 6794bf0..2062d15 100644 --- a/tests/test_maintenance_storage.py +++ b/tests/test_maintenance_storage.py @@ -303,6 +303,52 @@ def test_exit_off_echo_preserves_one_full_acquisition(self): finally: self.program.substitutions["maintenance_timeout"] = old + def test_storage_duplicate_on_keeps_pending_acceptance_cycle(self): + runtime = self.runtime() + runtime.execute("storage_control_on") + runtime.run_until(100) + runtime.execute("storage_control_on") + runtime.run_until(500) + runtime.execute("storage_control_on") + runtime.run_until(5000) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_storage_off_echo_keeps_one_acquisition_and_exit_refresh(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.storage_requested = True + runtime.execute("storage_control_off") + runtime.run_until(100) + runtime.execute("storage_control_off") + runtime.run_until(15100) + # exit_storage has now published retained cmd/storage_mode OFF. + runtime.execute("storage_control_off") + runtime.run_until(34000) + started = [v for _, k, v in runtime.events if k == "script.execute"] + self.assertEqual(started.count("acquire_normal"), 1) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) + self.assertEqual(self.updates(runtime).count("my_display"), 1) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_storage_changes_and_duplicates_during_maintenance_keep_latest_request(self): + runtime = self.runtime() + runtime.execute("maintenance_control_on") + runtime.run_until(2000) + before = list(self.updates(runtime)) + for command in ("on", "on", "off", "off"): + runtime.execute(f"storage_control_{command}") + self.assertEqual(self.updates(runtime), before) + self.assertFalse(runtime.state.storage_requested) + self.assertTrue(runtime.state.maintenance_active) + runtime.execute("maintenance_control_off") + runtime.run_until(26000) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) + self.assertEqual(runtime.sleep[1], 3600000) + def test_topic_contract_persistence_and_transport_separation(self): packages = ROOT / "examples/multi-device/packages" mqtt = yaml.load((packages / "smart_plant_profile_mqtt.yaml").read_text(), PackageLoader)