From 1c7b0550136e974f77aed4a0a7758efacb08ff45 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 1/3] feat: add retained MQTT maintenance and storage modes --- examples/multi-device/README.md | 72 ++- .../packages/smart_plant_core.yaml | 123 ++++- .../packages/smart_plant_profile_api.yaml | 41 ++ .../packages/smart_plant_profile_mqtt.yaml | 497 +++++++++++++++++- tests/test_display_recovery.py | 71 +++ tests/test_display_recovery_budget.py | 126 +++++ tests/test_low_battery.py | 14 +- tests/test_maintenance_storage.py | 431 +++++++++++++++ 8 files changed, 1338 insertions(+), 37 deletions(-) create mode 100644 tests/test_display_recovery.py create mode 100644 tests/test_display_recovery_budget.py create mode 100644 tests/test_maintenance_storage.py diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index 7236215..bf5b900 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -12,7 +12,8 @@ Assistant transport: Start from `my-lemon-tree.yaml` for MQTT or `my-lemon-tree-api.yaml` for the native API, set its substitutions and compose the core with exactly one -transport profile. Keep credentials in the local `secrets.yaml`; remote package +transport profile. The API profile clears a Storage latch left by the MQTT +profile and keeps the common low-battery latch. Keep credentials in the local `secrets.yaml`; remote package files contain no secrets. The MQTT example preserves an explicit topic prefix. Treat that prefix as a @@ -66,3 +67,72 @@ This command validates both actual example files with synthetic secrets, a local public background and packages from the current checkout. It does not compile or contact a device or MQTT broker. ESPHome can download Google Fonts. CI runs the same command so a PR is checked against its own package source. + +## Retained MQTT controls + +These controls belong to the MQTT profile. The API profile keeps the normal +measurement cycle. Compose the core and profile from the same revision. + +Publish `ON` or `OFF` with retain enabled and QoS 1: + +| Control | Command topic | Result topic | +| --- | --- | --- | +| Maintenance | `/cmd/maintenance` | `/status/maintenance` | +| Storage | `/cmd/storage_mode` | `/status/storage_mode` | + +`` is `mqtt_topic_prefix`. Command state expresses the requested mode. +Result state expresses the accepted mode. Home Assistant discovers both +controls and their diagnostic result entities. + +Maintenance has priority over Storage. Admission requires a finite battery +reading at or above `ota_min_battery` (50% by default). A lower reading returns +`REJECTED_LOW_BATTERY`; a missing or non-finite reading returns +`BATTERY_UNAVAILABLE`. Both results clear the retained Maintenance command. +A rejected request resumes Storage or acquires fresh plant readings for Normal +mode. The rejection result remains visible through that cycle. +Maintenance samples only the battery. Its page shows the window end time when +the clock is valid. A missing clock delays admission by at most ten seconds. + +Wait for Maintenance result `ON` before uploading firmware. This result follows +an observed panel BUSY HIGH-to-LOW refresh cycle and its settling guard. A +physical LOW without an observed assertion does not acknowledge readiness. +A repeated `ON` renews the window. `OFF` or the watchdog clears the retained +command, restores the selected mode and refreshes the display. Normal mode +first acquires fresh plant measurements. Changed requests received during +acquisition or display settlement cancel the older work; the latest requested +state wins. An echoed Maintenance `OFF` after rejection or exit starts no new +cycle. Equal Storage requests do not restart pending work. Maintenance `ON` +continues to renew its timed window. + +A successful OTA transfer clears the Maintenance request and cancels its +watchdog before a two-second MQTT flush. It starts no new display refresh. +When Storage was active, a persisted flag requests its page on the next boot. +A failed transfer leaves the current window under its watchdog. + +Storage persists in NVS. Each Storage wake samples only the battery, refreshes +the Storage page and sleeps for 24 hours. It keeps this behavior if the broker +is unavailable. Retained `OFF` restores normal acquisition on the next wake; +this can take up to the configured Storage sleep interval. A Storage command +received during Maintenance takes effect when Maintenance ends. + +Low-battery protection keeps priority over the Storage page and sleep duration. +The common core remains independent of MQTT commands. Both normal and emergency +sleep paths explicitly confirm the OTA image before shutdown. + +```yaml +substitutions: + ota_min_battery: "50" + maintenance_timeout_minutes: "25" + storage_sleep_duration: "24h" +``` + +Panel recovery uses one total 30-second budget across BUSY retries. A terminal +fault increments a persisted counter, resets the panel, waits for the reset +guard and enters emergency sleep. The MQTT profile publishes the counter at +`/status/display_fault_count`. A new refresh cancels all delayed work +from the previous refresh. ESPHome still runs its native shutdown handlers. + +Run the offline package tests with `python3 -B -m unittest discover -s tests -v`. +They execute the package actions and compile the policy lambdas as C++ with +simulated sensors, time and GPIO. Firmware compilation checks ESPHome schemas +and generated code. Neither check proves physical panel or network behavior. diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index dc32cac..1ff6260 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -55,6 +55,10 @@ substitutions: low_batt_exit_pct: "22" low_batt_warn_pct: "30" low_batt_sleep_duration: "24h" + ota_min_battery: "50" + maintenance_timeout_minutes: "25" + maintenance_timeout: "${maintenance_timeout_minutes}min" + storage_sleep_duration: "24h" # The legacy Waveshare driver returns before the active-high BUSY signal has # completed every physical refresh. These bounded windows prevent sleep from @@ -66,6 +70,30 @@ substitutions: display_busy_recovery_timeout: "30s" globals: + - id: storage_active + type: bool + restore_value: true + initial_value: 'false' + - id: maintenance_end_hour + type: int + restore_value: false + initial_value: '-1' + - id: maintenance_end_minute + type: int + restore_value: false + initial_value: '-1' + - id: display_settle_ok + type: bool + restore_value: false + initial_value: 'false' + - id: display_reset_pending + type: bool + restore_value: false + initial_value: 'false' + - id: display_busy_fault_count + type: int + restore_value: true + initial_value: '0' # Persist hysteresis across deep sleep and power loss. A fresh reading at the # exit threshold releases a restored latch after charging or battery exchange. - id: low_batt_active @@ -352,6 +380,30 @@ display: draw_gauge(id(temp).state, id(temp).has_state(), ${temp_min}, ${temp_max}, 188, 50, "%.0f°C"); draw_gauge(id(hum).state, id(hum).has_state(), ${hum_min}, ${hum_max}, 242, 70, "%.0f%%"); + - id: page_maintenance + lambda: |- + it.printf(148, 8, id(font_subtitle), TextAlign::TOP_CENTER, "${friendly_name}"); + it.printf(148, 38, id(font_title), TextAlign::TOP_CENTER, "MAINTENANCE"); + if (id(maintenance_end_hour) >= 0) { + it.printf(148, 82, id(font_title), TextAlign::TOP_CENTER, "ENDS AT %02d:%02d", + id(maintenance_end_hour), id(maintenance_end_minute)); + } else { + it.printf(148, 82, id(font_title), TextAlign::TOP_CENTER, + "ENDS IN ${maintenance_timeout_minutes} MIN"); + } + + - id: page_storage + lambda: |- + it.printf(148, 4, id(font_subtitle), TextAlign::TOP_CENTER, "${friendly_name}"); + it.printf(148, 31, id(font_title), TextAlign::TOP_CENTER, "STORAGE MODE"); + it.printf(148, 68, id(font_subtitle), TextAlign::TOP_CENTER, "DAILY CHECK"); + if (id(batpercent).has_state() && std::isfinite(id(batpercent).state)) { + it.printf(148, 91, id(font_title), TextAlign::TOP_CENTER, + "BATTERY %.0f%%", id(batpercent).state); + } else { + it.printf(148, 91, id(font_title), TextAlign::TOP_CENTER, "BATTERY --%%"); + } + # Critical protection is transport-independent and takes precedence over # the normal page until the persisted hysteresis latch releases. - id: page_low_batt @@ -359,6 +411,9 @@ display: it.fill(COLOR_ON); it.printf(148, 4, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "${friendly_name}"); it.printf(148, 26, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "LOW BATT !"); + if (id(storage_active)) { + it.printf(148, 60, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "STORAGE MODE"); + } if (id(batpercent).has_state() && std::isfinite(id(batpercent).state)) { it.printf(148, 88, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "BATTERY %.0f%%", id(batpercent).state); @@ -407,9 +462,9 @@ script: condition: lambda: 'return id(esptime).now().is_valid();' timeout: 10s - - script.execute: render_and_sleep + - script.execute: decide_sleep - - id: render_and_sleep + - id: render_measurement_page mode: restart then: - lambda: |- @@ -427,19 +482,12 @@ script: } } - display.page.show: !lambda |- - return id(low_batt_active) ? id(page_low_batt) : id(page1); + if (id(low_batt_active)) return id(page_low_batt); + return id(storage_active) ? id(page_storage) : id(page1); - script.execute: prepare_display_refresh - component.update: my_display - script.execute: settle_display - script.wait: settle_display - - if: - condition: - lambda: 'return id(display_safe_to_sleep);' - then: - - script.execute: enter_deep_sleep - else: - - deep_sleep.prevent: deep_sleep_control - - script.execute: recover_display_busy - id: prepare_display_refresh then: @@ -452,6 +500,7 @@ script: - script.stop: sleep_after_display - script.stop: handle_display_busy_fault - script.stop: force_sleep_after_busy_fault + - script.stop: on_display_fault - lambda: |- // Cancellation during the asynchronous reset pulse must release RST. if (id(display_reset_pending)) { @@ -544,9 +593,12 @@ script: - script.stop: sleep_after_display - lambda: |- id(display_safe_to_sleep) = false; + id(display_busy_fault_count) += 1; - logger.log: level: ERROR - format: "E-paper BUSY recovery failed or budget exhausted; reset then emergency sleep" + format: "E-paper BUSY recovery failed or budget exhausted - fault #%d; reset then emergency sleep" + args: + - 'id(display_busy_fault_count)' - lambda: |- pinMode(${display_reset_gpio}, OUTPUT); id(display_reset_pending) = true; @@ -560,8 +612,11 @@ script: # No new display refresh follows a terminal fault. Native shutdown hooks # remain enabled, including the stock driver's final SPI sleep command. + # Battery-priority and storage durations are preserved. - id: force_sleep_after_busy_fault then: + - script.execute: on_display_fault + - script.wait: on_display_fault # Keep this safeguard for users on arbitrary ESPHome versions. - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control @@ -574,19 +629,28 @@ script: id: deep_sleep_control sleep_duration: ${low_batt_sleep_duration} else: - - deep_sleep.enter: deep_sleep_control + - if: + condition: + lambda: 'return id(storage_active);' + then: + - deep_sleep.enter: + id: deep_sleep_control + sleep_duration: ${storage_sleep_duration} + else: + - deep_sleep.enter: deep_sleep_control - - id: sleep_after_display + - id: sample_battery mode: restart then: - - if: + - switch.turn_on: exc + - delay: 250ms + - component.update: max17043_id + - wait_until: condition: - lambda: 'return id(display_safe_to_sleep);' - then: - - script.execute: enter_deep_sleep - else: - - deep_sleep.prevent: deep_sleep_control - - script.execute: recover_display_busy + lambda: |- + return id(batpercent).has_state() && + std::isfinite(id(batpercent).state); + timeout: 3s - id: enter_deep_sleep mode: restart @@ -607,11 +671,26 @@ script: condition: lambda: 'return id(low_batt_active);' then: + # Protective hibernation: extend the cycle so residual drain + # cannot walk the cell toward deep-discharge damage while low. - deep_sleep.enter: id: deep_sleep_control sleep_duration: ${low_batt_sleep_duration} else: - - deep_sleep.enter: deep_sleep_control + - if: + condition: + lambda: 'return id(storage_active);' + then: + - deep_sleep.enter: + id: deep_sleep_control + sleep_duration: ${storage_sleep_duration} + else: + - deep_sleep.enter: deep_sleep_control else: + - logger.log: + level: WARN + format: "E-paper became BUSY before sleep; cancelling shutdown" + # The live pin overrides the cached result from settle/recovery. + # Both profiles must enter recovery instead of retrying the 1s delay. - lambda: 'id(display_safe_to_sleep) = false;' - script.execute: sleep_after_display diff --git a/examples/multi-device/packages/smart_plant_profile_api.yaml b/examples/multi-device/packages/smart_plant_profile_api.yaml index f42a3de..15127db 100644 --- a/examples/multi-device/packages/smart_plant_profile_api.yaml +++ b/examples/multi-device/packages/smart_plant_profile_api.yaml @@ -16,6 +16,8 @@ esphome: - lambda: 'delay(250);' - priority: 399.0 then: + # Storage belongs to MQTT. Clear a latch restored from an older profile. + - lambda: 'id(storage_active) = false;' - script.execute: acquire_normal api: @@ -25,3 +27,42 @@ api: ota: - platform: esphome password: "${ota_password}" + +script: + - id: on_display_fault + then: + - logger.log: + level: ERROR + format: "Display BUSY permanent fault #%d — emergency sleep" + args: + - 'id(display_busy_fault_count)' + - delay: 500ms + + # Simple sleep decision: no maintenance/storage. acquire_normal (core) ends by + # calling this; render the measurement page and sleep. + + - id: decide_sleep + mode: restart + then: + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - script.execute: sleep_after_display + + # API variant of sleep_after_display: same BUSY-safety logic as the MQTT + # profile, but the fault status is logged instead of published to an MQTT + # diagnostic entity (which does not exist in this profile). + + - id: sleep_after_display + mode: restart + then: + - if: + condition: + lambda: 'return id(display_safe_to_sleep);' + then: + - script.execute: enter_deep_sleep + else: + - deep_sleep.prevent: deep_sleep_control + - logger.log: + level: ERROR + format: "E-paper remains BUSY; starting bounded recovery" + - script.execute: recover_display_busy diff --git a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml index a7d807a..9cf64c3 100644 --- a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml +++ b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml @@ -1,10 +1,14 @@ # ============================================================================= -# Smart Plant — retained-MQTT transport profile +# Smart Plant — MQTT transport profile (advanced) # ============================================================================= -# Compose with smart_plant_core.yaml. Retained discovery/state keeps the latest -# readings visible while the device sleeps. The native API is enabled only for -# ESPHome dashboard metadata; do not also add the device through the ESPHome -# Home Assistant integration, which would create duplicate entities. +# Compose with smart_plant_core.yaml. Adds the MQTT data path (retained values +# survive deep sleep), retained remote control (Maintenance / Storage Mode), +# native ESPHome OTA (Device Builder push), and the boot orchestration. Requires +# deep-sleep-friendly retained state. +# +# packages: +# core: !include packages/smart_plant_core.yaml +# transport: !include packages/smart_plant_profile_mqtt.yaml # ============================================================================= substitutions: @@ -13,30 +17,81 @@ substitutions: mqtt_username: "" mqtt_password: "" +globals: + - id: maintenance_requested + type: bool + restore_value: false + initial_value: 'false' + - id: boot_sequence_complete + type: bool + restore_value: false + initial_value: 'false' + - id: maintenance_active + type: bool + restore_value: false + initial_value: 'false' + # The accepted seasonal state is the only Storage Mode value persisted. + # The requested state starts from it and is overridden by retained MQTT when + # the broker is reachable. + - id: storage_requested + type: bool + restore_value: false + initial_value: 'false' + - id: storage_display_refresh_pending + type: bool + restore_value: true + initial_value: 'false' + - id: preserve_maintenance_status + type: bool + restore_value: false + initial_value: 'false' + esphome: on_boot: + # GPIO switches initialize at HARDWARE priority 800 and MAX17043 at DATA + # priority 600. Block setup between them so the switched I2C rail is stable + # before the fuel gauge performs its one supported wake/config setup. - priority: 700 then: - lambda: 'delay(250);' + # Retained MQTT state handling suspends until MQTT is connected; this keeps + # cycle preparation after the remaining component setup work. - priority: 399.0 then: + - lambda: 'id(storage_requested) = id(storage_active);' - wait_until: condition: mqtt.connected: timeout: 30s - - script.execute: acquire_normal + # Retained desired-state commands arrive immediately after MQTT connect, + # but connection itself does not guarantee their callbacks have run. + - delay: 1s + - lambda: 'id(boot_sequence_complete) = true;' + - script.execute: prepare_cycle +# MQTT — required for deep-sleep compatibility: retained values survive sleep +# so Home Assistant sees fresh data on wake without "unavailable" state. +# MQTT is kept as the data source for Home Assistant (retained values survive +# deep sleep). The native API below is enabled ONLY so the ESPHome dashboard can +# read rich device metadata (mac / config_hash / version) via the _esphomelib._tcp +# mDNS record — MQTT-only devices advertise _http._tcp with `version` alone. +# Do NOT add these devices to Home Assistant via the ESPHome (API) integration, +# or you get duplicate entities that go unavailable during sleep. Keep them on MQTT. mqtt: + id: mqtt_client + topic_prefix: "${mqtt_topic_prefix}" broker: "${mqtt_broker}" username: "${mqtt_username}" password: "${mqtt_password}" discovery: true discovery_retain: true - birth_message: + birth_message: # disabled — prevents HA marking device offline during sleep will_message: - log_topic: - topic_prefix: "${mqtt_topic_prefix}" + log_topic: # disabled — saves bandwidth on wake +# Native API — dashboard metadata only (see note above). reboot_timeout: 0s so a +# sleeping device with no API client connected doesn't reboot itself. +# Supply api_encryption_key from the device's local secrets.yaml. api: encryption: key: "${api_encryption_key}" @@ -45,3 +100,427 @@ api: ota: - platform: esphome password: "${ota_password}" + # on_end runs only after the image has been transferred successfully and + # immediately before the OTA reboot. Clear the one-shot retained request so + # the new firmware performs a normal measurement/display/sleep cycle. + on_end: + then: + # The old deadline must not start a display refresh during this final + # retained-message flush before the OTA reboot. + - script.stop: maintenance_watchdog + - lambda: |- + id(maintenance_requested) = false; + id(maintenance_active) = false; + - switch.template.publish: + id: maintenance_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + payload: "OFF" + qos: 1 + retain: true + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - if: + condition: + lambda: 'return id(storage_active);' + then: + # Never start a physical refresh immediately before OTA reboot. + # Persist a one-shot request that the new image services after + # boot, where BUSY can be observed through completion. + - lambda: 'id(storage_display_refresh_pending) = true;' + - delay: 2s + +switch: + # Native MQTT-discovered control: ESPHome associates it with the same device + # as the plant sensors. Keep the established retained command topic so + # existing operators and automations remain compatible. + - platform: template + id: maintenance_control + name: "Maintenance" + icon: "mdi:wrench-cog" + optimistic: true + command_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + command_retain: true + subscribe_qos: 1 + qos: 1 + retain: true + turn_on_action: + - lambda: 'id(maintenance_requested) = true;' + # A repeated ON renews the window. The old deadline must not fire while + # the new battery sample or display refresh is still in progress. + - script.stop: maintenance_watchdog + - if: + condition: + lambda: 'return id(boot_sequence_complete);' + then: + - script.execute: prepare_cycle + turn_off_action: + - lambda: 'id(maintenance_requested) = false;' + - if: + condition: + lambda: 'return id(boot_sequence_complete);' + then: + - if: + condition: + lambda: 'return id(maintenance_active);' + then: + - script.stop: prepare_cycle + - script.execute: decide_sleep + else: + # 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 + name: "Storage Mode" + icon: "mdi:archive-outline" + optimistic: true + command_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/storage_mode";' + command_retain: true + 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: + - if: + condition: + lambda: 'return !id(storage_requested);' + then: + - lambda: 'id(storage_requested) = true;' + - if: + condition: + lambda: 'return id(boot_sequence_complete) && !id(maintenance_active);' + then: + - script.execute: prepare_cycle + turn_off_action: + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - 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 + # Assistant attaches it to the plant's MQTT device. REJECTED_LOW_BATTERY is + # intentionally distinct from the requested ON/OFF control state. + - platform: template + id: maintenance_status + name: "Maintenance Status" + icon: "mdi:information-outline" + entity_category: diagnostic + update_interval: never + state_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/status/maintenance";' + qos: 1 + retain: true + + - platform: template + id: storage_status + name: "Storage Mode Status" + icon: "mdi:archive-check-outline" + entity_category: diagnostic + update_interval: never + state_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/status/storage_mode";' + qos: 1 + retain: true + +script: + # Called by force_sleep_after_busy_fault (core) when a permanent BUSY fault + # triggers the emergency sleep path. Publishes the NVS-backed fault counter + # retained so Home Assistant reflects the current count after each wake. + - id: on_display_fault + then: + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/status/display_fault_count";' + payload: !lambda 'return to_string(id(display_busy_fault_count));' + qos: 1 + retain: true + - delay: 1s + + - id: sleep_after_display + mode: restart + then: + - if: + condition: + lambda: 'return id(display_safe_to_sleep);' + then: + - script.execute: enter_deep_sleep + else: + # Recover within one total budget. A terminal timeout resets the + # panel and enters the bounded emergency sleep path. + - deep_sleep.prevent: deep_sleep_control + - text_sensor.template.publish: + id: maintenance_status + state: "DISPLAY_BUSY_TIMEOUT" + - logger.log: + level: ERROR + format: "E-paper remains BUSY; starting bounded recovery" + - script.execute: recover_display_busy + + # Choose the smallest acquisition path only after retained MQTT commands have + # had a chance to override the persisted Storage Mode state. + - id: prepare_cycle + mode: restart + then: + # A newer retained command owns the whole pending cycle. Cancel children + # as well as this restartable parent before choosing the next acquisition. + - 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);' + then: + - script.execute: sample_battery + - script.wait: sample_battery + - wait_until: + condition: + lambda: 'return id(esptime).now().is_valid();' + timeout: 10s + - script.execute: decide_sleep + else: + - if: + condition: + lambda: 'return id(storage_requested);' + then: + # Every daily Storage wake updates the only operational + # sensor that matters while the plant sensors are gated. + - script.execute: sample_battery + - script.wait: sample_battery + - script.execute: decide_sleep + else: + - script.execute: acquire_normal + + - id: decide_sleep + mode: restart + then: + - if: + condition: + lambda: 'return id(maintenance_requested);' + then: + - if: + condition: + lambda: |- + return id(batpercent).has_state() && + std::isfinite(id(batpercent).state) && + id(batpercent).state >= ${ota_min_battery}; + then: + - lambda: |- + id(maintenance_active) = true; + auto now = id(esptime).now(); + if (now.is_valid()) { + int end_minutes = now.hour * 60 + now.minute + + ${maintenance_timeout_minutes}; + id(maintenance_end_hour) = (end_minutes / 60) % 24; + id(maintenance_end_minute) = end_minutes % 60; + } else { + id(maintenance_end_hour) = -1; + id(maintenance_end_minute) = -1; + } + - deep_sleep.prevent: deep_sleep_control + - display.page.show: page_maintenance + - script.execute: prepare_display_refresh + - component.update: my_display + - script.execute: settle_display + - script.wait: settle_display + - if: + condition: + lambda: 'return id(display_settle_ok);' + then: + # ON is the OTA readiness handshake and is emitted only + # after an observed BUSY HIGH -> LOW refresh cycle. + - text_sensor.template.publish: + id: maintenance_status + state: "ON" + - script.execute: maintenance_watchdog + else: + - text_sensor.template.publish: + id: maintenance_status + state: "DISPLAY_REFRESH_FAILED" + - script.execute: sleep_after_display + else: + - lambda: |- + id(maintenance_requested) = false; + id(maintenance_active) = false; + id(preserve_maintenance_status) = true; + - switch.template.publish: + id: maintenance_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + payload: "OFF" + qos: 1 + retain: true + - if: + condition: + lambda: |- + return id(batpercent).has_state() && + std::isfinite(id(batpercent).state); + then: + - text_sensor.template.publish: + id: maintenance_status + state: "REJECTED_LOW_BATTERY" + else: + - text_sensor.template.publish: + id: maintenance_status + state: "BATTERY_UNAVAILABLE" + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - script.execute: enter_storage + else: + # Admission samples only the battery. Acquire the plant + # states before returning to the normal display cycle. + - script.execute: acquire_normal + else: + - script.stop: maintenance_watchdog + - if: + condition: + lambda: 'return id(maintenance_active);' + then: + - script.execute: exit_maintenance + else: + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - script.execute: enter_storage + else: + - if: + condition: + lambda: 'return id(storage_active);' + then: + - script.execute: exit_storage + else: + - if: + condition: + lambda: 'return !id(preserve_maintenance_status);' + then: + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - text_sensor.template.publish: + id: storage_status + state: "OFF" + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - script.execute: sleep_after_display + + - id: maintenance_watchdog + mode: restart + then: + - delay: ${maintenance_timeout} + - script.execute: exit_maintenance + + - id: enter_storage + mode: single + then: + - lambda: |- + id(storage_active) = true; + id(storage_display_refresh_pending) = true; + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - if: + condition: + lambda: 'return id(display_settle_ok);' + then: + - lambda: 'id(storage_display_refresh_pending) = false;' + - switch.template.publish: + id: storage_control + state: ON + - text_sensor.template.publish: + id: storage_status + state: "ON" + - if: + condition: + lambda: 'return !id(preserve_maintenance_status);' + then: + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - if: + condition: + lambda: |- + return !id(storage_display_refresh_pending) && + id(display_safe_to_sleep); + then: + - script.execute: enter_deep_sleep + else: + - script.execute: sleep_after_display + + - id: exit_storage + mode: single + then: + - lambda: 'id(storage_active) = false;' + - switch.template.publish: + id: storage_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/storage_mode";' + payload: "OFF" + qos: 1 + retain: true + - text_sensor.template.publish: + id: storage_status + state: "OFF" + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - script.execute: sleep_after_display + + # The e-paper keeps its last image without power. Replace the maintenance + # page with the latest plant readings before an explicit OFF or watchdog + # sleep, so the physical display cannot contradict the retained MQTT state. + - id: exit_maintenance + mode: single + then: + - lambda: |- + id(maintenance_requested) = false; + id(maintenance_active) = false; + - switch.template.publish: + id: maintenance_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + payload: "OFF" + qos: 1 + retain: true + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - lambda: 'id(storage_display_refresh_pending) = true;' + - script.execute: enter_storage + else: + - if: + condition: + lambda: 'return id(storage_active);' + then: + - script.execute: exit_storage + else: + # Plant sensors are never sampled during maintenance, so their + # states are still NaN here. Run the full normal cycle so the + # post-maintenance e-paper refresh shows real readings instead + # of "nan%/nanlx/nan°C/nan%". acquire_normal ends with + # decide_sleep, which now takes the plain-sleep branch. + - script.execute: acquire_normal diff --git a/tests/test_display_recovery.py b/tests/test_display_recovery.py new file mode 100644 index 0000000..fb52210 --- /dev/null +++ b/tests/test_display_recovery.py @@ -0,0 +1,71 @@ +from pathlib import Path +import unittest + +from display_automation import DisplayProgram, DisplayRuntime + + +ROOT = Path(__file__).resolve().parents[1] + + +class LateBusyRecoveryTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.programs = {profile: DisplayProgram(ROOT, profile) for profile in ("api", "mqtt")} + + @classmethod + def tearDownClass(cls): + for program in cls.programs.values(): + program.close() + + def runtimes(self, busy): + for profile, program in self.programs.items(): + for reverse in (False, True): + runtime = DisplayRuntime(program, busy, reverse) + runtime.state.busy = busy(0) + yield runtime + + def test_late_permanent_high_reaches_emergency_sleep(self): + for runtime in self.runtimes(lambda ms: int(ms >= 500)): + runtime.execute("enter_deep_sleep") + runtime.run_until(34000) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + self.assertEqual(runtime.state.reset_low_count, 1) + self.assertEqual(runtime.state.reset_high_count, 1) + self.assertIn("recover_display_busy", [e[2] for e in runtime.events if e[1] == "script.execute"]) + + def test_late_transient_high_resumes_safe_sleep(self): + for runtime in self.runtimes(lambda ms: int(500 <= ms < 2000)): + runtime.execute("enter_deep_sleep") + runtime.run_until(5000) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.sleep[1], 3600000) + self.assertEqual(runtime.state.display_busy_fault_count, 0) + self.assertLess(runtime.sleep[0], 4000) + + def test_recovery_to_sleep_race_returns_to_recovery(self): + for runtime in self.runtimes(lambda ms: int(500 <= ms < 2000 or ms >= 3000)): + runtime.execute("enter_deep_sleep") + runtime.run_until(37000) + starts = [t for t, key, name in runtime.events + if key == "script.execute" and name == "recover_display_busy"] + self.assertEqual(starts, [1000, 3500]) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + + def test_normal_sleep_preserves_battery_and_storage_priority(self): + for runtime in self.runtimes(lambda ms: 0): + for low_battery, storage, duration in ((False, False, 3600000), + (False, True, 86400000), + (True, True, 86400000)): + trial = DisplayRuntime(runtime.program) + trial.state.low_batt_active = low_battery + trial.state.storage_active = storage + trial.execute("enter_deep_sleep") + trial.run_until(2000) + self.assertEqual(trial.sleep, (1000, duration)) + self.assertEqual(trial.state.display_busy_fault_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_display_recovery_budget.py b/tests/test_display_recovery_budget.py new file mode 100644 index 0000000..e80166d --- /dev/null +++ b/tests/test_display_recovery_budget.py @@ -0,0 +1,126 @@ +from pathlib import Path +import unittest + +from display_automation import DisplayProgram, DisplayRuntime + + +ROOT = Path(__file__).resolve().parents[1] + + +class RecoveryBudgetTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.programs = {profile: DisplayProgram(ROOT, profile) for profile in ("api", "mqtt")} + + @classmethod + def tearDownClass(cls): + for program in cls.programs.values(): + program.close() + + def runtimes(self, busy): + for program in self.programs.values(): + for reverse in (False, True): + for refresh_index in range(len(program.refresh_sequences)): + runtime = DisplayRuntime(program, busy, reverse) + runtime.refresh_index = refresh_index + runtime.state.busy = busy(0) + runtime.state.display_safe_to_sleep = False + yield runtime + + def test_repeated_low_then_high_during_sleep_delay_has_one_budget(self): + # Every LOW lasts long enough to pass the 500ms guard, but HIGH returns + # before the final 1s sleep delay finishes. Each individual wait succeeds. + for runtime in self.runtimes(lambda ms: int(ms % 2000 >= 600)): + runtime.execute("recover_display_busy") + runtime.run_until(33000) + self.assertIsNotNone(runtime.sleep) + self.assertLessEqual(runtime.sleep[0], 31530) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + self.assertEqual(runtime.state.reset_low_count, 1) + self.assertEqual(runtime.state.reset_high_count, 1) + self.assertFalse([event for event in runtime.events if event[1] == "component.update"]) + + def test_permanent_high_late_low_and_guard_oscillation_terminate(self): + signals = (lambda ms: 1, + lambda ms: int(ms < 29900), + lambda ms: int(ms % 600 >= 300)) + for busy in signals: + for runtime in self.runtimes(busy): + runtime.execute("recover_display_busy") + runtime.run_until(33000) + self.assertIsNotNone(runtime.sleep) + self.assertLessEqual(runtime.sleep[0], 31530) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + + def test_terminal_sleep_retains_distinct_battery_and_storage_durations(self): + for runtime in self.runtimes(lambda ms: 1): + old = runtime.program.substitutions["low_batt_sleep_duration"] + runtime.program.substitutions["low_batt_sleep_duration"] = "12h" + try: + for battery, storage, duration in ((False, False, 3600000), + (False, True, 86400000), + (True, True, 43200000)): + trial = DisplayRuntime(runtime.program, lambda ms: 1) + trial.state.busy = 1 + trial.state.display_safe_to_sleep = False + trial.state.low_batt_active = battery + trial.state.storage_active = storage + trial.execute("recover_display_busy") + trial.run_until(33000) + self.assertIsNotNone(trial.sleep) + self.assertEqual(trial.sleep[1], duration) + finally: + runtime.program.substitutions["low_batt_sleep_duration"] = old + + def test_new_refresh_cancels_recovery_guard_sleep_and_terminal_work(self): + # Cancel across wait, recovery guard, final sleep delay, reset pulse and + # fault notification. A later LOW completes the new settle normally. + stages = ((100, lambda ms: 1), + (250, lambda ms: 0), + (2000, lambda ms: int(ms < 1000)), + (30000, lambda ms: 1), + (30200, lambda ms: 1), + (30800, lambda ms: 1)) + for cancel_at, signal in stages: + for runtime in self.runtimes(signal): + runtime.execute("recover_display_busy") + runtime.run_until(cancel_at) + # A fresh refresh asserts BUSY, then clears within its own wait. + runtime.busy = lambda ms: int(ms < cancel_at + 1000) + runtime.state.busy = 1 + runtime.refresh(runtime.refresh_index) + runtime.run_until(cancel_at + 4000) + self.assertIsNone(runtime.sleep) + self.assertTrue(runtime.state.display_safe_to_sleep) + self.assertFalse(runtime.tasks) + self.assertEqual(runtime.state.reset_low_count, runtime.state.reset_high_count) + + def test_new_refresh_starts_a_fresh_budget(self): + for runtime in self.runtimes(lambda ms: 1): + runtime.execute("recover_display_busy") + runtime.run_until(1000) + runtime.busy = lambda ms: int(ms < 2000) + runtime.refresh(runtime.refresh_index) + runtime.run_until(4000) + runtime.busy = lambda ms: 1 + runtime.state.busy = 1 + runtime.state.display_safe_to_sleep = False + runtime.execute("recover_display_busy") + runtime.run_until(33000) + self.assertIsNone(runtime.sleep) + runtime.run_until(36000) + self.assertIsNotNone(runtime.sleep) + self.assertGreaterEqual(runtime.sleep[0], 35010) + self.assertLessEqual(runtime.sleep[0], 35530) + + def test_successful_recovery_cancels_budget_before_normal_sleep(self): + for runtime in self.runtimes(lambda ms: int(ms < 1000)): + runtime.execute("recover_display_busy") + runtime.run_until(4000) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.state.display_busy_fault_count, 0) + self.assertNotIn("display_recovery_budget", runtime.tasks) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_low_battery.py b/tests/test_low_battery.py index 5b6e9a4..874fa6c 100644 --- a/tests/test_low_battery.py +++ b/tests/test_low_battery.py @@ -43,6 +43,8 @@ def resolve(source, overrides=None): struct Sensor { float state = 50; bool available = true; bool has_state() { return available; } }; Sensor batpercent, soil, light, temp, hum; bool low_batt_active = false; +bool storage_active = false; +int page_storage = 3; int page1 = 1, page_low_batt = 2; int font_title, font_subtitle, font_parameters, font_icon, page_1_background; enum Color { COLOR_OFF, COLOR_ON }; @@ -129,8 +131,8 @@ def resolve(source, overrides=None): class LowBatteryTests(unittest.TestCase): def source(self, overrides=None): - evaluation = SCRIPTS["render_and_sleep"]["then"][0]["lambda"] - selection = SCRIPTS["render_and_sleep"]["then"][1]["display.page.show"] + evaluation = SCRIPTS["render_measurement_page"]["then"][0]["lambda"] + selection = SCRIPTS["render_measurement_page"]["then"][1]["display.page.show"] source = HARNESS for marker, body in (("EVALUATE", evaluation), ("SELECT_PAGE", selection), ("NORMAL_PAGE", PAGES["page1"]), ("CRITICAL_PAGE", PAGES["page_low_batt"])): @@ -166,11 +168,11 @@ def test_latch_is_persistent_and_core_has_no_transport_policy(self): self.assertTrue(latch["restore_value"]) self.assertEqual(latch["initial_value"], "false") self.assertNotIn("mqtt", PACKAGE) - self.assertNotIn("storage_active", CORE.read_text()) + self.assertNotIn("mqtt.publish", CORE.read_text()) for profile in ("api", "mqtt"): text = (CORE.parent / f"smart_plant_profile_{profile}.yaml").read_text() self.assertIn("script.execute: acquire_normal", text) - self.assertIn({"script.execute": "render_and_sleep"}, SCRIPTS["acquire_normal"]["then"]) + self.assertIn({"script.execute": "decide_sleep"}, SCRIPTS["acquire_normal"]["then"]) def test_normal_and_emergency_sleep_keep_confirmation_and_battery_duration(self): paths = [] @@ -199,7 +201,9 @@ def find(value): self.assertEqual(decision["condition"]["lambda"], "return id(low_batt_active);") self.assertEqual(decision["then"], [{"deep_sleep.enter": { "id": "deep_sleep_control", "sleep_duration": "${low_batt_sleep_duration}"}}]) - self.assertEqual(decision["else"], [{"deep_sleep.enter": "deep_sleep_control"}]) + storage = decision["else"][0]["if"] + self.assertEqual(storage["condition"]["lambda"], "return id(storage_active);") + self.assertEqual(storage["else"], [{"deep_sleep.enter": "deep_sleep_control"}]) if __name__ == "__main__": diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py new file mode 100644 index 0000000..16156e8 --- /dev/null +++ b/tests/test_maintenance_storage.py @@ -0,0 +1,431 @@ +"""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(30000) + 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"), 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"), 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_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).count("my_display"), 1) + self.assertEqual(self.updates(runtime).count("max17043_id"), 1 if storage else 2) + self.assertEqual(self.updates(runtime).count("soil"), 0 if storage else 5) + 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"), 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_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"), 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"), 5) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_rejected_maintenance_acquires_initially_missing_plant_states(self): + runtime = self.runtime() + runtime.state.batpercent.state = 40 + for name in ("temp", "hum", "light", "soil"): + sensor = getattr(runtime.state, name) + sensor.available = False + sensor.state = math.nan + readings = {"aht20_sensor": {"temp": 21, "hum": 45}, + "veml7700_sensor": {"light": 123}, "soil": {"soil": 42}} + def update(component, state): + for name, value in readings.get(component, {}).items(): + sensor = getattr(state, name) + sensor.state = value + sensor.available = True + runtime.sensor_update = update + runtime.execute("maintenance_control_on") + runtime.run_until(500) + runtime.execute("maintenance_control_off") # retained rejection echo + runtime.run_until(26000) + for name, value in {"temp": 21, "hum": 45, "light": 123, "soil": 42}.items(): + self.assertTrue(getattr(runtime.state, name).available) + self.assertEqual(getattr(runtime.state, name).state, value) + self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(runtime.state.selected_page, runtime.state.page1) + self.assertEqual(runtime.sleep[1], 3600000) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], + ["REJECTED_LOW_BATTERY"]) + + 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))) + + +class APIStorageIsolationTests(unittest.TestCase): + def test_restored_mqtt_storage_cannot_change_api_page_or_sleep(self): + program = DisplayProgram(ROOT, "api", control=True) + try: + for battery, latched, expected_low in [(80, False, False), (80, True, False), + (20, True, True), (math.nan, True, True)]: + with self.subTest(battery=battery, latched=latched): + runtime = DisplayRuntime(program) + runtime.state.storage_active = True + runtime.state.low_batt_active = latched + runtime.state.batpercent.state = battery + def busy(ms): + updates = [t for t, k, v in runtime.events + if k == "component.update" and v == "my_display"] + return int(bool(updates) and 50 <= ms - updates[-1] < 350) + runtime.busy = busy + runtime.execute("boot") + runtime.run_until(30000) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(runtime.state.low_batt_active, expected_low) + self.assertEqual(runtime.state.selected_page, + runtime.state.page_low_batt if expected_low else runtime.state.page1) + self.assertEqual(runtime.sleep[1], 86400000 if expected_low else 3600000) + finally: + program.close() + + +if __name__ == "__main__": + unittest.main() From c5ea999cbf647d3dadc1f8ddab38a959493ceb65 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 10:48:13 +0200 Subject: [PATCH 2/3] test: adapt mode checks to merged soil diagnostics and portable libraries --- tests/test_maintenance_storage.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py index 16156e8..d940ecf 100644 --- a/tests/test_maintenance_storage.py +++ b/tests/test_maintenance_storage.py @@ -195,7 +195,7 @@ def test_storage_off_reacquires_plant_sensors_before_normal_sleep(self): 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"), 5) + 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): @@ -238,7 +238,7 @@ def test_off_cancels_pending_sample_time_wait_and_maintenance_refresh(self): 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"), 5) + 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): @@ -273,7 +273,7 @@ def test_rejection_off_echo_keeps_result_and_does_not_start_another_cycle(self): runtime.run_until(24000) self.assertEqual(self.updates(runtime).count("my_display"), 1) self.assertEqual(self.updates(runtime).count("max17043_id"), 1 if storage else 2) - self.assertEqual(self.updates(runtime).count("soil"), 0 if storage else 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 0 if storage else 5) self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], ["REJECTED_LOW_BATTERY"]) self.assertEqual(runtime.state.storage_active, storage) @@ -298,7 +298,7 @@ def test_exit_off_echo_preserves_one_full_acquisition(self): 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"), 5) + 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) @@ -330,7 +330,7 @@ def test_storage_off_echo_keeps_one_acquisition_and_exit_refresh(self): 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"), 5) + 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) @@ -348,7 +348,7 @@ def test_storage_changes_and_duplicates_during_maintenance_keep_latest_request(s runtime.execute("maintenance_control_off") runtime.run_until(26000) self.assertFalse(runtime.state.storage_active) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual(runtime.sleep[1], 3600000) def test_rejected_maintenance_acquires_initially_missing_plant_states(self): @@ -359,7 +359,7 @@ def test_rejected_maintenance_acquires_initially_missing_plant_states(self): sensor.available = False sensor.state = math.nan readings = {"aht20_sensor": {"temp": 21, "hum": 45}, - "veml7700_sensor": {"light": 123}, "soil": {"soil": 42}} + "veml7700_sensor": {"light": 123}, "soil_voltage": {"soil": 42}} def update(component, state): for name, value in readings.get(component, {}).items(): sensor = getattr(state, name) @@ -373,7 +373,7 @@ def update(component, state): for name, value in {"temp": 21, "hum": 45, "light": 123, "soil": 42}.items(): self.assertTrue(getattr(runtime.state, name).available) self.assertEqual(getattr(runtime.state, name).state, value) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual(runtime.state.selected_page, runtime.state.page1) self.assertEqual(runtime.sleep[1], 3600000) self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], From 086faa36501c68b0d36172cc3084745b27546cc6 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 17:36:05 +0200 Subject: [PATCH 3/3] fix: reconcile maintenance/storage modes with the shared display recovery Drop the globals duplicated by the #31 merge. Let the parity test accept the multi-device extensions (fault hook, counter, logs, MQTT status) and the per-profile sleep_after_display. Bound the flow tests by each profile's on_display_fault delay. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017fn1KdJJTY7LedVqZ7SxcA --- .../packages/smart_plant_core.yaml | 8 ---- tests/test_standalone_display.py | 48 +++++++++++++++---- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 1ff6260..e18286c 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -82,14 +82,6 @@ globals: type: int restore_value: false initial_value: '-1' - - id: display_settle_ok - type: bool - restore_value: false - initial_value: 'false' - - id: display_reset_pending - type: bool - restore_value: false - initial_value: 'false' - id: display_busy_fault_count type: int restore_value: true diff --git a/tests/test_standalone_display.py b/tests/test_standalone_display.py index 3dbe1f7..b4191a8 100644 --- a/tests/test_standalone_display.py +++ b/tests/test_standalone_display.py @@ -123,6 +123,8 @@ def test_out_of_range_text_stays_raw_and_needles_match_endpoints(self): class DisplayFlowScenarios: standalone = True profile = None + # Profile on_display_fault hook (log or MQTT flush) before the sleep request. + fault_hook_ms = 0 @classmethod def setUpClass(cls): cls.program = DisplayProgram(ROOT, profile=cls.profile, standalone=cls.standalone) @@ -166,9 +168,9 @@ def test_fault_and_oscillation_reach_one_bounded_sleep_request(self): runtime = self.runtime(signal, reverse) runtime.state.display_safe_to_sleep = False runtime.execute("recover_display_busy") - runtime.run_until(32000) + runtime.run_until(32000 + self.fault_hook_ms) self.assert_valid_sleep(runtime) - self.assertLessEqual(runtime.sleep[0], 30530) + self.assertLessEqual(runtime.sleep[0], 30530 + self.fault_hook_ms) self.assertEqual(runtime.state.reset_low_count, 1) self.assertEqual(runtime.state.reset_high_count, 1) self.assertFalse([e for e in runtime.events if e[1] == "component.update"]) @@ -196,10 +198,10 @@ def test_new_refresh_starts_a_fresh_recovery_budget(self): runtime.execute("recover_display_busy") runtime.run_until(33000) self.assertIsNone(runtime.sleep) - runtime.run_until(35000) + runtime.run_until(35000 + self.fault_hook_ms) self.assert_valid_sleep(runtime) self.assertGreaterEqual(runtime.sleep[0], 34000) - self.assertLessEqual(runtime.sleep[0], 34530) + self.assertLessEqual(runtime.sleep[0], 34530 + self.fault_hook_ms) def test_new_refresh_cancels_wait_guard_sleep_reset_and_old_notification(self): for cancel, signal in [(100, lambda ms: 1), (250, lambda ms: 0), @@ -242,16 +244,42 @@ def test_normal_and_fault_sleep_preserve_configurable_protection_duration(self): class RecoveryConsistencyTests(unittest.TestCase): + # Multi-device extensions on top of the shared recovery: fault hook and + # persisted counter, logs, and the MQTT status entity. Everything else must + # stay identical to the public standalone example. + EXTENSION_ACTIONS = ("logger.log", "text_sensor.template.publish") + + @classmethod + def without_extensions(cls, node): + if isinstance(node, list): + return [cls.without_extensions(child) for child in node + if not (isinstance(child, dict) and + (child.keys() & set(cls.EXTENSION_ACTIONS) or + child.get("script.stop") == "on_display_fault"))] + if isinstance(node, dict): + return {key: cls.without_extensions(value) for key, value in node.items()} + if isinstance(node, str): + return "\n".join(line for line in node.split("\n") + if "display_busy_fault_count" not in line) + return node + def test_shared_recovery_actions_match_standalone(self): public = yaml.load(PUBLIC.read_text(encoding="utf-8"), PackageLoader) - core_path = ROOT / "examples/multi-device/packages/smart_plant_core.yaml" - core = yaml.load(core_path.read_text(encoding="utf-8"), PackageLoader) public_scripts = {script["id"]: script for script in public["script"]} - core_scripts = {script["id"]: script for script in core["script"]} + packages = ROOT / "examples/multi-device/packages" + loaded = {name: yaml.load((packages / f"smart_plant_{name}.yaml").read_text(encoding="utf-8"), + PackageLoader) for name in ("core", "profile_api", "profile_mqtt")} for name in ("prepare_display_refresh", "settle_display", "recover_display_busy", - "display_recovery_budget", "handle_display_busy_fault", "sleep_after_display"): + "display_recovery_budget", "handle_display_busy_fault"): with self.subTest(script=name): - self.assertEqual(core_scripts[name], public_scripts[name]) + core = {script["id"]: script for script in loaded["core"]["script"]}[name] + self.assertEqual(self.without_extensions(core), + self.without_extensions(public_scripts[name])) + for profile in ("profile_api", "profile_mqtt"): + with self.subTest(script="sleep_after_display", profile=profile): + scripts = {script["id"]: script for script in loaded[profile]["script"]} + self.assertEqual(self.without_extensions(scripts["sleep_after_display"]), + self.without_extensions(public_scripts["sleep_after_display"])) class StandaloneFlowTests(DisplayFlowScenarios, unittest.TestCase): @@ -261,11 +289,13 @@ class StandaloneFlowTests(DisplayFlowScenarios, unittest.TestCase): class CoreApiFlowTests(DisplayFlowScenarios, unittest.TestCase): standalone = False profile = "api" + fault_hook_ms = 500 class CoreMqttFlowTests(DisplayFlowScenarios, unittest.TestCase): standalone = False profile = "mqtt" + fault_hook_ms = 1000 if __name__ == "__main__":