Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion examples/multi-device/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 | `<prefix>/cmd/maintenance` | `<prefix>/status/maintenance` |
| Storage | `<prefix>/cmd/storage_mode` | `<prefix>/status/storage_mode` |

`<prefix>` 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
`<prefix>/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.
115 changes: 93 additions & 22 deletions examples/multi-device/packages/smart_plant_core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -66,6 +70,22 @@ 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_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
Expand Down Expand Up @@ -352,13 +372,40 @@ 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
lambda: |-
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);
Expand Down Expand Up @@ -407,9 +454,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: |-
Expand All @@ -427,19 +474,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:
Expand All @@ -452,6 +492,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)) {
Expand Down Expand Up @@ -544,9 +585,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;
Expand All @@ -560,8 +604,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
Expand All @@ -574,19 +621,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
Expand All @@ -607,11 +663,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
41 changes: 41 additions & 0 deletions examples/multi-device/packages/smart_plant_profile_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Loading
Loading