From 815c2c65811fc84e2ba6b9c294e73a21492bbc40 Mon Sep 17 00:00:00 2001 From: flowcool Date: Mon, 15 Jun 2026 19:49:03 +0200 Subject: [PATCH 01/10] RFC: multi-device support via ESPHome packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a discussion proposal — not a merge request for the current single-device configuration.yaml. ## Context I'm running 8 Smart Plant boards at home. With the current flat configuration.yaml, maintaining 8 copies means: - every bug fix or improvement must be applied 8 times - calibration values are scattered across 8 files - inconsistencies creep in over time ## Proposed structure examples/multi-device/ ├── packages/ │ └── smart_plant_base.yaml # all shared config (~250 lines) └── my-lemon-tree.yaml # per-plant overrides (~20 lines) The base package contains everything that is currently in configuration.yaml. Each device file only defines its substitutions (name, timezone, label image, soil calibration, gauge ranges) and includes the package with a single !include. Benefits: - A bug fix in smart_plant_base.yaml propagates to all devices on the next OTA — no copy-paste - Soil calibration is clearly visible per device, not buried in a shared file - New plant = 15-line file, not a 400-line copy Also included in the base: - All fixes from PRs #18 (SNTP), #19 (power_save_mode / timezone) - draw_gauge refactored as a C++ lambda to eliminate the 4×50-line repeated block - device_class: battery on the battery_level sensor ## Questions for @JGAguado 1. Would you consider adding this as an `examples/` directory rather than replacing configuration.yaml? The single-file config would remain the default for users with one plant; this would be an opt-in for multi-plant setups. 2. The draw_gauge lambda refactor inside the display lambda — is that acceptable in terms of ESPHome compatibility across versions? 3. Are there plans to migrate the project to esp-idf framework? That would affect some of the deep sleep optimisations. Note: I've been working on this refactoring with AI assistance (Claude Code) for my personal multi-plant setup. The PRs in this series are the result of a careful diff between the upstream config and my production setup. Happy to iterate on any of this. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/my-lemon-tree.yaml | 23 ++ .../packages/smart_plant_base.yaml | 290 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 examples/multi-device/my-lemon-tree.yaml create mode 100644 examples/multi-device/packages/smart_plant_base.yaml diff --git a/examples/multi-device/my-lemon-tree.yaml b/examples/multi-device/my-lemon-tree.yaml new file mode 100644 index 0000000..cdc8a69 --- /dev/null +++ b/examples/multi-device/my-lemon-tree.yaml @@ -0,0 +1,23 @@ +# ============================================================================= +# My Lemon Tree — device-specific config +# ============================================================================= +# Only override what's different from the base package defaults. +# Everything else (sensors, display, deep sleep logic) is shared. +# ============================================================================= + +substitutions: + device_name: "lemon-tree" + friendly_name: "Lemon Tree" + timezone: "Europe/Paris" + label_image: "https://smart-plant.readthedocs.io/en/v2r1/_images/Lemon_tree_label_page_1.png" + + # Lemon tree soil calibration (adjust to your sensor after measuring + # in dry air and fully submerged in water) + soil_v_wet: "1.25" + soil_v_dry: "2.8" + + # Lemon trees tolerate more light than average + light_max: "8000" + +packages: + base: !include packages/smart_plant_base.yaml diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml new file mode 100644 index 0000000..7facfd3 --- /dev/null +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -0,0 +1,290 @@ +# ============================================================================= +# Smart Plant — shared base package +# ============================================================================= +# RFC: see https://github.com/JGAguado/Smart_Plant/pull/XXX +# +# Usage: create a device file (see ../my-lemon-tree.yaml) that defines only +# the substitutions specific to that plant, then include this package. +# Every change made here propagates to all devices on the next OTA flash. +# ============================================================================= + +# Default values — override any of these in your device file. +substitutions: + device_name: "smart-plant" + friendly_name: "Smart Plant" + project_name: "smart.plant" + project_version: "2.2" + ap_pwd: "smartplant" + timezone: "UTC" + # Background image for the e-paper display (plant label) + label_image: "https://smart-plant.readthedocs.io/en/v2r1/_images/Lemon_tree_label_page_1.png" + # Soil calibration (ADC voltage → moisture %) + soil_v_wet: "1.25" # voltage when sensor is fully submerged + soil_v_dry: "2.8" # voltage in dry air + # Gauge display ranges + light_max: "3775" + temp_min: "-10" + temp_max: "50" + hum_min: "20" + hum_max: "80" + +esphome: + name: "${device_name}" + name_add_mac_suffix: true + project: + name: "${project_name}" + version: "${project_version}" + on_boot: + priority: 600 + then: + - lambda: |- + Wire.begin(); + delay(100); + - wait_until: + condition: + lambda: 'return id(esptime).now().is_valid();' + timeout: 30s + - script.execute: consider_deep_sleep + +esp32: + board: esp32-s2-saola-1 + framework: + type: arduino + +logger: + +api: + +# Uncomment to use MQTT instead of the native API (avoids "unavailable" +# state in HA during deep sleep — see issue #16 for details). +# mqtt: +# broker: !secret mqtt_broker +# discovery: true +# discovery_retain: true +# birth_message: +# will_message: +# log_topic: + +ota: + - platform: esphome + +dashboard_import: + package_import_url: github://JGAguado/Smart_Plant/docs/source/files/configuration.yaml@V2R1 + import_full_config: false + +captive_portal: +improv_serial: + +wifi: + fast_connect: true + power_save_mode: none + ap: + password: "${ap_pwd}" + +i2c: + scl: GPIO34 + sda: GPIO33 + scan: false + id: bus_a + +spi: + clk_pin: GPIO12 + mosi_pin: GPIO11 + +image: + - file: "${label_image}" + id: page_1_background + type: binary + invert_alpha: true + +font: + - file: "gfonts://Audiowide" + id: font_title + size: 20 + - file: "gfonts://Audiowide" + id: font_subtitle + size: 15 + - file: "gfonts://Audiowide" + id: font_parameters + size: 15 + - file: 'gfonts://Material+Symbols+Outlined' + id: font_icon + size: 20 + glyphs: + - "\U0000ebdc" + - "\U0000ebd9" + - "\U0000ebe0" + - "\U0000ebdd" + - "\U0000ebe2" + - "\U0000ebd4" + - "\U0000e1a4" + - "\U0000e627" + +time: + - platform: sntp + id: esptime + timezone: "${timezone}" + +switch: + - platform: gpio + pin: GPIO4 + id: exc + icon: "mdi:power" + restore_mode: ALWAYS_ON + internal: true + +sensor: + - platform: max17043 + id: max17043_id + battery_voltage: + id: batvolt + name: "${friendly_name} Battery voltage" + internal: true + force_update: true + battery_level: + id: batpercent + name: "${friendly_name} Battery" + device_class: battery + force_update: true + + - platform: aht10 + variant: AHT20 + i2c_id: bus_a + temperature: + name: "${friendly_name} Temperature" + id: temp + icon: "mdi:thermometer" + device_class: temperature + force_update: true + humidity: + name: "${friendly_name} Air Humidity" + id: hum + icon: "mdi:water-percent" + device_class: humidity + force_update: true + update_interval: 3s + + - platform: veml7700 + address: 0x10 + update_interval: 3s + ambient_light: + name: "${friendly_name} Ambient light" + id: light + icon: "mdi:white-balance-sunny" + device_class: illuminance + force_update: true + actual_gain: + name: "Actual gain" + internal: true + + - platform: adc + pin: GPIO1 + name: "${friendly_name} Soil Moisture" + id: soil + device_class: moisture + icon: "mdi:cup-water" + update_interval: 3s + unit_of_measurement: "%" + attenuation: 12db + force_update: true + filters: + - median: + window_size: 5 + send_every: 5 + - calibrate_linear: + - ${soil_v_wet} -> 100.00 + - ${soil_v_dry} -> 0.00 + - lambda: if (x < 1) return 0; else if (x > 100) return 100; return (x); + accuracy_decimals: 0 + +display: + - platform: waveshare_epaper + cs_pin: GPIO10 + dc_pin: GPIO13 + busy_pin: GPIO14 + reset_pin: GPIO15 + rotation: 270 + model: 2.90inv2 + id: my_display + update_interval: never + full_update_every: 1 + pages: + - id: page1 + lambda: |- + #define H_LEFT_MARGIN 4 + #define H_RIGHT_MARGIN 280 + #define H_CENTER 128 + + it.image(0, 0, id(page_1_background)); + + float battery_perc = id(batpercent).state; + int battery_range = battery_perc / 16; + battery_range = (battery_range > 6) ? 6 : battery_range; + battery_range = (battery_range < 0) ? 0 : battery_range; + + const char* battery_icon_map[] = { + "\U0000ebdc", "\U0000ebd9", "\U0000ebe0", "\U0000ebdd", + "\U0000ebe2", "\U0000ebd4", "\U0000e1a4" + }; + it.printf(278, 1, id(font_icon), TextAlign::TOP_LEFT, battery_icon_map[battery_range]); + it.printf(278, 1, id(font_subtitle), TextAlign::TOP_RIGHT, "%3.0f%%", battery_perc); + + auto now = id(esptime).now(); + if (now.is_valid()) { + it.strftime(278, 18, id(font_subtitle), TextAlign::TOP_RIGHT, "%H:%M %d/%m", now); + } else { + it.printf(278, 18, id(font_subtitle), TextAlign::TOP_RIGHT, "--:-- --/--"); + } + it.printf(278, 18, id(font_icon), TextAlign::TOP_LEFT, "\U0000e627"); + + float pi = 3.141592653589793; + float alpha = 4.71238898038469; + float beta = 2*pi - alpha; + int radius = 22; + int thick = 7; + + auto draw_gauge = [&](float value, float vmin, float vmax, int xc, int yc, const char* fmt) { + if (value < vmin) value = vmin; + if (value > vmax) value = vmax; + float val = (value - vmin) / abs(vmax - vmin) * alpha; + int x0 = static_cast(xc + radius + radius * cos(pi / 2 + beta / 2 + val)); + int y0 = static_cast(yc + radius + radius * sin(pi / 2 + beta / 2 + val)); + int x1 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val + 0.1)); + int y1 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val + 0.1)); + int x2 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val - 0.1)); + int y2 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val - 0.1)); + it.line(x0, y0, x1, y1); + it.line(x1, y1, x2, y2); + it.line(x2, y2, x0, y0); + it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, fmt, value); + }; + + draw_gauge(id(soil).state, 0, 100, 80, 50, "%.0f%%"); + draw_gauge(id(light).state, 0, ${light_max}, 134, 70, "%.0flx"); + draw_gauge(id(temp).state, ${temp_min}, ${temp_max}, 188, 50, "%.0f°C"); + draw_gauge(id(hum).state, ${hum_min}, ${hum_max}, 242, 70, "%.0f%%"); + +deep_sleep: + id: deep_sleep_control + sleep_duration: 1h + +script: + - id: consider_deep_sleep + mode: queued + then: + - delay: 5s + - component.update: my_display + - delay: 5s + - if: + condition: + sensor.in_range: + id: batpercent + above: 95 + then: + - deep_sleep.prevent: deep_sleep_control + else: + - switch.turn_off: exc + - max17043.sleep_mode: max17043_id + - deep_sleep.enter: deep_sleep_control + - delay: 25s + - script.execute: consider_deep_sleep From fafac2a8c3d0230c8db5c8f29e72ca2ce913cfbe Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 16 Jun 2026 07:42:55 +0200 Subject: [PATCH 02/10] feat: add MQTT support and missing substitutions to base package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace api: with mqtt: (MQTT required for deep-sleep compatibility) - Add substitutions: device_comment, mqtt_topic_prefix, ota_password, ap_ssid — all previously hardcoded per-device - Improve on_boot: Wire.begin() + wait for mqtt.connected + wait for valid SNTP time (replaces fixed 20s delay, fixes 1970 display bug) - Update my-lemon-tree.yaml to show static IP override pattern and github:// package URL for zero-local-copy deployment Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/my-lemon-tree.yaml | 29 ++++- .../packages/smart_plant_base.yaml | 109 +++++++++++------- 2 files changed, 88 insertions(+), 50 deletions(-) diff --git a/examples/multi-device/my-lemon-tree.yaml b/examples/multi-device/my-lemon-tree.yaml index cdc8a69..dd02acf 100644 --- a/examples/multi-device/my-lemon-tree.yaml +++ b/examples/multi-device/my-lemon-tree.yaml @@ -1,18 +1,28 @@ # ============================================================================= # My Lemon Tree — device-specific config # ============================================================================= -# Only override what's different from the base package defaults. -# Everything else (sensors, display, deep sleep logic) is shared. +# Only define what's specific to this device. Everything else comes from the +# base package (sensors, display, deep sleep, MQTT, fonts...). # ============================================================================= substitutions: device_name: "lemon-tree" friendly_name: "Lemon Tree" + device_comment: "Citrus limon / Lemon Tree" + + # MQTT topic prefix — set once and never change (renaming orphans HA entities). + # Format: -, visible in ESPHome logs on first boot. + mqtt_topic_prefix: "lemon-tree-abcdef" + + # OTA password for this device + ota_password: "replace-with-your-ota-password" + + # Plant label image (place PNG in esphome/plant_labels/) + label_image: "plant_labels/Lemon_tree_label_page_1.png" + timezone: "Europe/Paris" - label_image: "https://smart-plant.readthedocs.io/en/v2r1/_images/Lemon_tree_label_page_1.png" - # Lemon tree soil calibration (adjust to your sensor after measuring - # in dry air and fully submerged in water) + # Soil calibration — measure your specific sensor in dry air and submerged soil_v_wet: "1.25" soil_v_dry: "2.8" @@ -20,4 +30,11 @@ substitutions: light_max: "8000" packages: - base: !include packages/smart_plant_base.yaml + base: github://flowcool/Smart_Plant/examples/multi-device/packages/smart_plant_base.yaml@V2R1 + +# Static IP — merges on top of base wifi block. Remove this block for DHCP. +wifi: + manual_ip: + static_ip: 192.168.1.100 + gateway: 192.168.1.1 + subnet: 255.255.255.0 diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index 7facfd3..f48bd28 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -1,26 +1,41 @@ # ============================================================================= # Smart Plant — shared base package # ============================================================================= -# RFC: see https://github.com/JGAguado/Smart_Plant/pull/XXX -# # Usage: create a device file (see ../my-lemon-tree.yaml) that defines only -# the substitutions specific to that plant, then include this package. -# Every change made here propagates to all devices on the next OTA flash. +# the substitutions specific to that plant, then reference this package via: +# +# packages: +# base: github://flowcool/Smart_Plant/examples/multi-device/packages/smart_plant_base.yaml@V2R1 +# +# Every change pushed here propagates to all devices on the next OTA flash. # ============================================================================= # Default values — override any of these in your device file. substitutions: device_name: "smart-plant" friendly_name: "Smart Plant" + device_comment: "${friendly_name}" project_name: "smart.plant" project_version: "2.2" + ap_ssid: "Smart-Plant" ap_pwd: "smartplant" - timezone: "UTC" - # Background image for the e-paper display (plant label) + timezone: "Europe/Paris" + + # MQTT topic prefix — must match retained prefix already in Home Assistant. + # Format: -. Set once, never change + # (renaming orphans all entities in HA). + mqtt_topic_prefix: "${device_name}" + + # OTA password (leave empty string for no password) + ota_password: "" + + # Background image for the e-paper display — local path or URL label_image: "https://smart-plant.readthedocs.io/en/v2r1/_images/Lemon_tree_label_page_1.png" - # Soil calibration (ADC voltage → moisture %) + + # Soil moisture calibration (ADC voltage → %) soil_v_wet: "1.25" # voltage when sensor is fully submerged soil_v_dry: "2.8" # voltage in dry air + # Gauge display ranges light_max: "3775" temp_min: "-10" @@ -31,20 +46,25 @@ substitutions: esphome: name: "${device_name}" name_add_mac_suffix: true + comment: "${device_comment}" project: name: "${project_name}" version: "${project_version}" on_boot: priority: 600 then: - - lambda: |- - Wire.begin(); - delay(100); - - wait_until: - condition: - lambda: 'return id(esptime).now().is_valid();' - timeout: 30s - - script.execute: consider_deep_sleep + - lambda: |- + Wire.begin(); + delay(100); + - wait_until: + condition: + mqtt.connected: + timeout: 30s + - wait_until: + condition: + lambda: 'return id(esptime).now().is_valid();' + timeout: 30s + - script.execute: consider_deep_sleep esp32: board: esp32-s2-saola-1 @@ -52,33 +72,38 @@ esp32: type: arduino logger: + level: WARN -api: +# MQTT — required for deep-sleep compatibility: retained values survive sleep +# so Home Assistant sees fresh data on wake without "unavailable" state. +# To use native API instead, remove this block and uncomment `api:` below +# (deep sleep will still work but HA may briefly show entities as unavailable). +mqtt: + broker: !secret mqtt_broker + username: !secret mqtt_username + password: !secret mqtt_password + discovery: true + discovery_retain: true + birth_message: # disabled — prevents HA marking device offline during sleep + will_message: + log_topic: # disabled — saves bandwidth on wake + topic_prefix: "${mqtt_topic_prefix}" -# Uncomment to use MQTT instead of the native API (avoids "unavailable" -# state in HA during deep sleep — see issue #16 for details). -# mqtt: -# broker: !secret mqtt_broker -# discovery: true -# discovery_retain: true -# birth_message: -# will_message: -# log_topic: +# api: ota: - platform: esphome - -dashboard_import: - package_import_url: github://JGAguado/Smart_Plant/docs/source/files/configuration.yaml@V2R1 - import_full_config: false + password: "${ota_password}" captive_portal: improv_serial: wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password fast_connect: true - power_save_mode: none ap: + ssid: "${ap_ssid}" password: "${ap_pwd}" i2c: @@ -94,7 +119,7 @@ spi: image: - file: "${label_image}" id: page_1_background - type: binary + type: BINARY invert_alpha: true font: @@ -140,11 +165,11 @@ sensor: id: batvolt name: "${friendly_name} Battery voltage" internal: true - force_update: true battery_level: id: batpercent + device_class: "battery" + unit_of_measurement: "%" name: "${friendly_name} Battery" - device_class: battery force_update: true - platform: aht10 @@ -174,15 +199,15 @@ sensor: device_class: illuminance force_update: true actual_gain: - name: "Actual gain" + name: "${friendly_name} Actual gain" internal: true - platform: adc pin: GPIO1 name: "${friendly_name} Soil Moisture" id: soil - device_class: moisture icon: "mdi:cup-water" + device_class: moisture update_interval: 3s unit_of_measurement: "%" attenuation: 12db @@ -211,10 +236,6 @@ display: pages: - id: page1 lambda: |- - #define H_LEFT_MARGIN 4 - #define H_RIGHT_MARGIN 280 - #define H_CENTER 128 - it.image(0, 0, id(page_1_background)); float battery_perc = id(batpercent).state; @@ -259,10 +280,10 @@ display: it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, fmt, value); }; - draw_gauge(id(soil).state, 0, 100, 80, 50, "%.0f%%"); - draw_gauge(id(light).state, 0, ${light_max}, 134, 70, "%.0flx"); - draw_gauge(id(temp).state, ${temp_min}, ${temp_max}, 188, 50, "%.0f°C"); - draw_gauge(id(hum).state, ${hum_min}, ${hum_max}, 242, 70, "%.0f%%"); + draw_gauge(id(soil).state, 0, 100, 80, 50, "%.0f%%"); + draw_gauge(id(light).state, 0, ${light_max}, 134, 70, "%.0flx"); + draw_gauge(id(temp).state, ${temp_min}, ${temp_max}, 188, 50, "%.0f°C"); + draw_gauge(id(hum).state, ${hum_min}, ${hum_max}, 242, 70, "%.0f%%"); deep_sleep: id: deep_sleep_control @@ -279,7 +300,7 @@ script: condition: sensor.in_range: id: batpercent - above: 95 + above: 90 then: - deep_sleep.prevent: deep_sleep_control else: From 1be4e52fe3f7e2e07c57fbd96ede6a4ba9127ed5 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 16 Jun 2026 07:54:38 +0200 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20remove=20Wire.begin()=20from=20on?= =?UTF-8?q?=5Fboot=20=E2=80=94=20ESPHome=20i2c=20component=20handles=20ini?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire is not in scope in ESPHome lambdas without explicit include; the i2c: component already initializes the bus before on_boot runs. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index f48bd28..16fd7eb 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -53,9 +53,6 @@ esphome: on_boot: priority: 600 then: - - lambda: |- - Wire.begin(); - delay(100); - wait_until: condition: mqtt.connected: From 73fd100eab8c37489a1cb0e6b69aeafef24af0db Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 16 Jun 2026 08:26:19 +0200 Subject: [PATCH 04/10] feat: add use_address substitution for reliable OTA/log connections Bypasses mDNS resolution (which fails with name_add_mac_suffix: true). Device files set use_address to their static IP; default falls back to device_name for setups without static IP. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index 16fd7eb..3f33ed5 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -29,6 +29,10 @@ substitutions: # OTA password (leave empty string for no password) ota_password: "" + # Address used by ESPHome CLI/dashboard for OTA and log connections. + # Default: device hostname via mDNS. Set to static IP for reliability. + use_address: "${device_name}" + # Background image for the e-paper display — local path or URL label_image: "https://smart-plant.readthedocs.io/en/v2r1/_images/Lemon_tree_label_page_1.png" @@ -99,6 +103,7 @@ wifi: ssid: !secret wifi_ssid password: !secret wifi_password fast_connect: true + use_address: "${use_address}" ap: ssid: "${ap_ssid}" password: "${ap_pwd}" From d2e2ce3088f0dcda187409a55bca73c9f3bb7f7b Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 16 Jun 2026 08:50:46 +0200 Subject: [PATCH 05/10] fix(sntp): replace wait_until with on_time_sync for robust time display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the 30s blocking wait for SNTP in on_boot — display already handles the missing-time case gracefully with dashes. Adds on_time_sync callback instead: display refreshes immediately the moment SNTP syncs, regardless of network latency. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index 3f33ed5..2770262 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -61,10 +61,6 @@ esphome: condition: mqtt.connected: timeout: 30s - - wait_until: - condition: - lambda: 'return id(esptime).now().is_valid();' - timeout: 30s - script.execute: consider_deep_sleep esp32: @@ -151,6 +147,9 @@ time: - platform: sntp id: esptime timezone: "${timezone}" + on_time_sync: + then: + - component.update: my_display switch: - platform: gpio From 3eae973ed3030cb4362c931b23758cd8064d062b Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 16 Jun 2026 08:58:25 +0200 Subject: [PATCH 06/10] fix(sntp): add multiple public NTP servers for faster sync pool.ntp.org alone can be slow on some networks (ESP32 UDP stack init latency + retry cycles). Adding cloudflare and google as fallbacks increases the chance of a fast first sync without any local IP dependency. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index 2770262..c60569e 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -147,6 +147,10 @@ time: - platform: sntp id: esptime timezone: "${timezone}" + servers: + - "pool.ntp.org" + - "time.cloudflare.com" + - "time.google.com" on_time_sync: then: - component.update: my_display From e3022348a28587e37f0f834a248559ab968f2f8b Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 16 Jun 2026 09:02:23 +0200 Subject: [PATCH 07/10] fix(sntp): use IP addresses as primary NTP servers to bypass DNS DNS resolution can fail on ESP32 depending on router/DHCP config. Using Cloudflare (162.159.200.1) and Google (216.239.35.0) stable anycast IPs as primary servers with pool.ntp.org as hostname fallback. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index c60569e..7a06189 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -148,9 +148,9 @@ time: id: esptime timezone: "${timezone}" servers: + - "162.159.200.1" # Cloudflare NTP (anycast, IP stable) + - "216.239.35.0" # Google NTP - "pool.ntp.org" - - "time.cloudflare.com" - - "time.google.com" on_time_sync: then: - component.update: my_display From 4030e104cfe64cab5e28fb07a9f7a6345cf1feca Mon Sep 17 00:00:00 2001 From: flowcool Date: Thu, 18 Jun 2026 10:40:42 +0200 Subject: [PATCH 08/10] feat: cpu_frequency 160MHz + wifi power_save_mode LIGHT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESP32-S2 default changed to 240MHz in ESPHome 2026.4 — cap at 160MHz to avoid ~3 mAh/day extra drain during wake windows on battery devices. Add power_save_mode: LIGHT during wake for additional WiFi savings. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index 7a06189..2a3a53e 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -67,6 +67,7 @@ esp32: board: esp32-s2-saola-1 framework: type: arduino + cpu_frequency: 160MHz logger: level: WARN @@ -99,6 +100,7 @@ wifi: ssid: !secret wifi_ssid password: !secret wifi_password fast_connect: true + power_save_mode: LIGHT use_address: "${use_address}" ap: ssid: "${ap_ssid}" From c02f917e8f5f47c52e79bfe9022e7e2dc5ef2018 Mon Sep 17 00:00:00 2001 From: flowcool Date: Thu, 18 Jun 2026 11:31:00 +0200 Subject: [PATCH 09/10] feat: enable api alongside mqtt for mDNS dashboard discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both api: and mqtt: can coexist — MQTT keeps deep-sleep retained values, api: adds mDNS advertisement (_esphomelib._tcp.local.) so the ESPHome dashboard discovers devices via mDNS instead of ping only. Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index 2a3a53e..6ce7a79 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -87,7 +87,7 @@ mqtt: log_topic: # disabled — saves bandwidth on wake topic_prefix: "${mqtt_topic_prefix}" -# api: +api: ota: - platform: esphome From 926c3366755d44a63f8814259bd65ba7e63d521a Mon Sep 17 00:00:00 2001 From: flowcool Date: Thu, 18 Jun 2026 11:46:16 +0200 Subject: [PATCH 10/10] =?UTF-8?q?revert:=20comment=20out=20api:=20?= =?UTF-8?q?=E2=80=94=20not=20needed=20with=20ping-based=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- examples/multi-device/packages/smart_plant_base.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_base.yaml b/examples/multi-device/packages/smart_plant_base.yaml index 6ce7a79..1527010 100644 --- a/examples/multi-device/packages/smart_plant_base.yaml +++ b/examples/multi-device/packages/smart_plant_base.yaml @@ -74,7 +74,7 @@ logger: # MQTT — required for deep-sleep compatibility: retained values survive sleep # so Home Assistant sees fresh data on wake without "unavailable" state. -# To use native API instead, remove this block and uncomment `api:` below +# To use native API instead, remove this block and uncomment `# api:` below # (deep sleep will still work but HA may briefly show entities as unavailable). mqtt: broker: !secret mqtt_broker @@ -87,7 +87,7 @@ mqtt: log_topic: # disabled — saves bandwidth on wake topic_prefix: "${mqtt_topic_prefix}" -api: +# api: ota: - platform: esphome