Files
esphome-configs/mcu-m5paper.yaml
T
randy 329830e553 mcu-m5paper: rework on M5Stack components, battery mode with forecast
Rewrite of the old config on the core ESPHome components (it8951, gt911,
bm8563, sht3xd):
- USB: always on, forecast screen redrawn every 15 min, forecast
  refreshed every hour and when Home Assistant connects
- battery: BM8563 timer wakes the board every 15 min (off 1:00-5:00),
  it draws the stored forecast and cuts power; WiFi only when the
  forecast is older than 6 h
- mode detected by trying to power off; battery voltage drop on USB
  triggers the battery cycle
- hourly forecast via weather.get_forecasts, 48 h kept in the FM24C02
  EEPROM (include/m5paper_weather.h)
- OTA image confirmed before power-off, battery wake-ups are shorter
  than boot_is_good_after
- status screen with "Made for ESPHome" logo, IPv6 addresses first
2026-09-27 19:49:57 +02:00

652 lines
22 KiB
YAML

---
substitutions:
device: m5paper
name: M5Paper
area: DEV
comment: M5Paper
weather_entity: weather.openweathermap
# Power modes
# - USB: always on, WiFi + API, forecast refreshed every hour and redrawn every 15 min
# - battery: RTC wakes the board every 15 min (off 1:00-5:00), it draws the forecast
# from EEPROM and powers itself off; WiFi only to refresh the forecast every 6 h
# Screens: status (after power-on, until Home Assistant connects) and forecast
# The mode is detected by trying to power off: on USB the board keeps running.
esphome:
name: mcu-${device}
friendly_name: ${name}
area: ${area}
comment: ${comment}
includes:
- include/m5paper_weather.h
on_boot:
- priority: 600
then:
- lambda: |-
// BM8563 CONTROL_STATUS_2: TF (bit 2) is set when the timer woke us up
const uint8_t reg = 0x01;
uint8_t status = 0;
if (id(i2c_main)->write_readv(0x51, &reg, 1, &status, 1) == i2c::ERROR_OK) {
id(rtc_wake) = status & 0x04;
// clear TF and TIE so the RTC releases the power latch
const uint8_t clear[2] = {reg, uint8_t(status & ~0x05)};
id(i2c_main)->write(0x51, clear, 2);
}
m5paper_weather::load(id(i2c_main), m5paper_weather::forecast());
ESP_LOGI("m5paper", "Wake-up: %s, forecast: %u h", id(rtc_wake) ? "RTC" : "power-on", m5paper_weather::forecast().count);
- priority: -100
then:
- if:
condition:
lambda: return id(rtc_wake);
then:
- script.execute: battery_cycle
else:
# power button / USB: stay awake for a while (logs, OTA), then decide the mode
- wifi.enable:
# IT8951 init runs asynchronously after setup
- delay: 3s
- component.update: m5paper_display
- delay: 2min
- script.execute: battery_cycle
esp32:
variant: esp32
toolchain: esp-idf
framework:
type: esp-idf
advanced:
flash_chip: generic
minimum_chip_revision: 3.0
sram1_as_iram: true
psram:
packages:
common: !include common/common.yaml
globals:
- id: show_status
type: bool
initial_value: 'true'
- id: usb_mode
type: bool
initial_value: 'false'
- id: rtc_wake
type: bool
initial_value: 'false'
# battery cycle running: forecast screen regardless of the connection state
- id: weather_page
type: bool
initial_value: 'false'
- id: fetch_done
type: bool
initial_value: 'false'
- id: sleep_s
type: int
initial_value: '900'
wifi:
enable_on_boot: false
on_connect:
- if:
condition:
lambda: return id(show_status) && !id(weather_page);
then:
- component.update: m5paper_display
api:
# status screen until Home Assistant connects (log viewers don't subscribe to states)
on_client_connected:
- delay: 2s
- if:
condition:
api.connected:
state_subscription_only: true
then:
- lambda: id(show_status) = false;
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
# outside the battery cycle: refresh a forecast older than an hour
- if:
condition:
and:
- lambda: |-
const auto &fc = m5paper_weather::forecast();
const auto now = id(rtc_time).now();
return !id(weather_page) && (!fc.valid() || !now.is_valid() || now.timestamp - fc.fetched_at >= 3600);
- api.connected:
state_subscription_only: true
then:
- script.execute: fetch_forecast
on_client_disconnected:
- if:
condition:
not:
api.connected:
state_subscription_only: true
then:
- lambda: id(show_status) = true;
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
# I2C devices (i2c_main)
# 0x44 SHT30
# 0x50 FM24C02 EEPROM (forecast storage, see include/m5paper_weather.h)
# 0x51 BM8563 RTC
# 0x5D GT911 touch (docs say 0x14; the driver tries 0x5D, then 0x14)
i2c:
- id: i2c_main
sda: 21
scl: 22
scan: true
# Port A
# - id: i2c_a
# sda: 25
# scl: 32
# Port B
# - id: i2c_b
# sda: 26
# scl: 33
# Port C
# - id: i2c_c
# sda: 18
# scl: 19
spi:
clk_pin: 14
miso_pin: 13
mosi_pin: 12
switch:
# Power control MOS (no PMU on M5Paper); on battery the device powers off when this goes low
- platform: gpio
id: main_power
pin:
number: GPIO2
ignore_strapping_warning: true
restore_mode: ALWAYS_ON
internal: true
# External power (Grove ports)
- platform: gpio
id: ext_power
pin:
number: GPIO5
ignore_strapping_warning: true
restore_mode: ALWAYS_ON
internal: true
script:
# One wake-up on battery: refresh the forecast if it is old, draw it, power off
- id: battery_cycle
mode: single
then:
- lambda: id(weather_page) = true;
- wait_until:
condition:
lambda: return id(rtc_time).now().is_valid();
timeout: 10s
- if:
condition:
lambda: |-
const auto &fc = m5paper_weather::forecast();
const auto now = id(rtc_time).now();
return !fc.valid() || !now.is_valid() || now.timestamp - fc.fetched_at >= 6 * 3600 - 600;
then:
- wifi.enable:
- wait_until:
condition:
api.connected:
state_subscription_only: true
timeout: 45s
- if:
condition:
api.connected:
state_subscription_only: true
then:
- script.execute: fetch_forecast
- wait_until:
condition:
lambda: return id(fetch_done);
timeout: 15s
- wait_until:
condition:
lambda: return !std::isnan(id(current_temperature).state);
timeout: 5s
- component.update: m5paper_display
# let the e-paper refresh finish before cutting power
- delay: 4s
- script.execute: try_sleep
- id: fetch_forecast
mode: single
then:
- lambda: id(fetch_done) = false;
- homeassistant.action:
action: weather.get_forecasts
data:
entity_id: ${weather_entity}
type: hourly
capture_response: true
on_success:
- lambda: |-
auto &fc = m5paper_weather::forecast();
if (m5paper_weather::parse(response, "${weather_entity}", id(rtc_time).now().timestamp, fc)) {
const bool saved = m5paper_weather::save(id(i2c_main), fc);
ESP_LOGI("m5paper", "Forecast: %u h%s", fc.count, saved ? "" : " (EEPROM write failed)");
} else {
ESP_LOGW("m5paper", "Forecast: unexpected response");
}
id(fetch_done) = true;
# the battery cycle draws on its own
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
on_error:
- lambda: |-
ESP_LOGW("m5paper", "Forecast: %s", error.c_str());
id(fetch_done) = true;
# Arm the RTC and cut power; if we are still running afterwards, we are on USB
- id: try_sleep
mode: single
then:
- lambda: |-
// next quarter hour, 1:00-5:00 in one go
int duration = 900;
const auto now = id(rtc_time).now();
if (now.is_valid()) {
const int sod = now.hour * 3600 + now.minute * 60 + now.second;
int next = (sod / 900 + 1) * 900;
if (next - sod < 90) // the 1/60 Hz RTC timer may wake us up to a minute early
next += 900;
const int next_hour = (next / 3600) % 24;
if (next_hour >= 1 && next_hour < 5)
next = (next / 86400) * 86400 + 5 * 3600;
duration = next - sod;
}
if (duration > 255) // longer timers count whole minutes
duration = (duration + 59) / 60 * 60;
id(sleep_s) = std::min(duration, 255 * 60);
ESP_LOGI("m5paper", "Powering off for %d s", id(sleep_s));
- bm8563.start_timer:
duration: !lambda return id(sleep_s);
# a battery wake-up is shorter than boot_is_good_after: confirm the OTA image
# before cutting power, otherwise the bootloader rolls back
- safe_mode.mark_successful:
- delay: 200ms
- switch.turn_off: main_power
- delay: 2s
# still alive -> USB power
- switch.turn_on: main_power
- lambda: |-
ESP_LOGI("m5paper", "Still running, USB power");
id(usb_mode) = true;
id(weather_page) = false;
id(show_status) = !api::global_api_server->is_connected_with_state_subscription();
- wifi.enable:
- component.update: m5paper_display
interval:
# USB mode: the battery voltage drops once USB is unplugged -> go to battery mode
- interval: 5min
then:
- if:
condition:
lambda: |-
static float last = NAN;
const float v = id(battery_voltage).state;
const bool dropped = id(usb_mode) && !std::isnan(last) && last - v > 0.03f;
last = v;
return dropped;
then:
- lambda: |-
ESP_LOGI("m5paper", "Battery voltage dropping, checking USB");
id(usb_mode) = false;
- script.execute: battery_cycle
time:
- id: !extend ha_time
on_time_sync:
- bm8563.write_time
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
on_time:
- seconds: 0
minutes: /15
then:
- if:
condition:
lambda: return !id(show_status) && !id(weather_page);
then:
- component.update: m5paper_display
# USB mode: keep the stored forecast fresh for battery mode
- seconds: 0
minutes: 5
then:
- if:
condition:
and:
- lambda: return id(usb_mode);
- api.connected:
state_subscription_only: true
then:
- script.execute: fetch_forecast
- platform: bm8563
id: rtc_time
binary_sensor:
- platform: gpio
name: ${name} Button UP
id: button_up
pin:
number: GPIO37
inverted: true
on_press:
- component.update: m5paper_display
- platform: gpio
name: ${name} Button Press
id: button_press
pin:
number: GPIO38
inverted: true
- platform: gpio
name: ${name} Button Down
id: button_down
pin:
number: GPIO39
inverted: true
- platform: touchscreen
name: ${name} Top Left Touch Button
id: top_left_touch_button
touchscreen_id: gt911_touchscreen
internal: true
x_min: 0
x_max: 100
y_min: 0
y_max: 100
on_press:
- script.execute: battery_cycle
button:
- platform: template
name: Refresh Display
entity_category: config
on_press:
- component.update: m5paper_display
- platform: template
name: Refresh Forecast
entity_category: config
on_press:
- script.execute: fetch_forecast
sensor:
- platform: sht3xd
temperature:
name: Temperature
id: current_temperature
humidity:
name: Humidity
id: current_humidity
address: 0x44
update_interval: 60s
- platform: adc
id: battery_voltage
name: Battery Voltage
pin: GPIO35
attenuation: 12db
samples: 16
update_interval: 60s
entity_category: diagnostic
filters:
- multiply: 2.0 # 1:2 voltage divider
- platform: copy
source_id: battery_voltage
id: battery_level
name: Battery
unit_of_measurement: "%"
device_class: battery
accuracy_decimals: 0
filters:
- calibrate_linear:
- 3.3 -> 0.0
- 4.15 -> 100.0
- clamp:
min_value: 0
max_value: 100
.image_defaults:
icon_large: &icon_large {platform: file, type: BINARY, transparency: chroma_key, resize: 150x150}
icon_small: &icon_small {platform: file, type: BINARY, transparency: chroma_key, resize: 44x44}
image:
- platform: file
file: assets/images/made-for-esphome-white-on-black.svg
id: esphome_logo
resize: 460x136
type: GRAYSCALE
transparency: alpha_channel
# weather icons, same order as m5paper_weather::CONDITIONS
- {<<: *icon_large, file: "mdi:weather-night", id: wl_clear_night}
- {<<: *icon_large, file: "mdi:weather-cloudy", id: wl_cloudy}
- {<<: *icon_large, file: "mdi:alert-circle-outline", id: wl_exceptional}
- {<<: *icon_large, file: "mdi:weather-fog", id: wl_fog}
- {<<: *icon_large, file: "mdi:weather-hail", id: wl_hail}
- {<<: *icon_large, file: "mdi:weather-lightning", id: wl_lightning}
- {<<: *icon_large, file: "mdi:weather-lightning-rainy", id: wl_lightning_rainy}
- {<<: *icon_large, file: "mdi:weather-partly-cloudy", id: wl_partlycloudy}
- {<<: *icon_large, file: "mdi:weather-pouring", id: wl_pouring}
- {<<: *icon_large, file: "mdi:weather-rainy", id: wl_rainy}
- {<<: *icon_large, file: "mdi:weather-snowy", id: wl_snowy}
- {<<: *icon_large, file: "mdi:weather-snowy-rainy", id: wl_snowy_rainy}
- {<<: *icon_large, file: "mdi:weather-sunny", id: wl_sunny}
- {<<: *icon_large, file: "mdi:weather-windy", id: wl_windy}
- {<<: *icon_large, file: "mdi:weather-windy-variant", id: wl_windy_variant}
- {<<: *icon_small, file: "mdi:weather-night", id: ws_clear_night}
- {<<: *icon_small, file: "mdi:weather-cloudy", id: ws_cloudy}
- {<<: *icon_small, file: "mdi:alert-circle-outline", id: ws_exceptional}
- {<<: *icon_small, file: "mdi:weather-fog", id: ws_fog}
- {<<: *icon_small, file: "mdi:weather-hail", id: ws_hail}
- {<<: *icon_small, file: "mdi:weather-lightning", id: ws_lightning}
- {<<: *icon_small, file: "mdi:weather-lightning-rainy", id: ws_lightning_rainy}
- {<<: *icon_small, file: "mdi:weather-partly-cloudy", id: ws_partlycloudy}
- {<<: *icon_small, file: "mdi:weather-pouring", id: ws_pouring}
- {<<: *icon_small, file: "mdi:weather-rainy", id: ws_rainy}
- {<<: *icon_small, file: "mdi:weather-snowy", id: ws_snowy}
- {<<: *icon_small, file: "mdi:weather-snowy-rainy", id: ws_snowy_rainy}
- {<<: *icon_small, file: "mdi:weather-sunny", id: ws_sunny}
- {<<: *icon_small, file: "mdi:weather-windy", id: ws_windy}
- {<<: *icon_small, file: "mdi:weather-windy-variant", id: ws_windy_variant}
font:
- file: "gfonts://Roboto"
id: font_title
size: 48
- file: "gfonts://Roboto"
id: font_status
size: 28
glyphs: &sk_glyphs "ÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽáäčďéíĺľňóôŕšťúýž°·"
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto"
id: font_small
size: 24
glyphs: *sk_glyphs
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto"
id: font_tiny
size: 18
glyphs: "0123456789% "
- file: "gfonts://Roboto@500"
id: font_date
size: 34
glyphs: *sk_glyphs
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto@500"
id: font_hour
size: 28
glyphs: "0123456789-°"
- file: "gfonts://Roboto"
id: font_temp
size: 110
glyphs: "0123456789.,-°C "
color:
- id: ink
red: 0%
green: 0%
blue: 0%
- id: paper
red: 100%
green: 100%
blue: 100%
display:
- platform: it8951
id: m5paper_display
model: m5stack-m5paper
rotation: 270
update_interval: never
# 1bpp: updates use the fast DU waveform, full GC16 refresh every 30 updates
grayscale: false
dithering: false
lambda: |-
// 540x960 portrait
it.fill(id(paper));
const int w = it.get_width();
const int cx = w / 2;
const float batt = id(battery_level).state;
const float temp = id(current_temperature).state;
const float hum = id(current_humidity).state;
// status screen after power-on, until Home Assistant connects
if (id(show_status) && !id(weather_page)) {
it.image(cx, 200, id(esphome_logo), ImageAlign::CENTER);
it.print(cx, 320, id(font_title), id(ink), TextAlign::CENTER, "${name}");
const bool wifi_ok = wifi::global_wifi_component->is_connected();
if (wifi_ok) {
it.printf(40, 440, id(font_status), id(ink), "WiFi: connected (%d dBm)", wifi::global_wifi_component->wifi_rssi());
// IPv6 first (skip link-local), then IPv4
int y = 484;
char buf[network::IP_ADDRESS_BUFFER_SIZE];
for (bool v6 : {true, false}) {
for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) {
if (!ip.is_set() || ip.is_ip6() != v6 || y > 590)
continue;
ip.str_to(buf);
if (v6 && strncmp(buf, "fe80", 4) == 0)
continue;
it.print(60, y, id(font_small), id(ink), buf);
y += 34;
}
}
} else {
it.print(40, 440, id(font_status), id(ink), "WiFi: connecting...");
}
const bool api_ok = api::global_api_server->is_connected_with_state_subscription();
it.print(40, 620, id(font_status), id(ink),
api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...");
if (!std::isnan(batt))
it.printf(40, 670, id(font_status), id(ink), "Battery: %.0f%% (%.2f V)", batt, id(battery_voltage).state);
it.printf(40, 720, id(font_status), id(ink), "Forecast: %u h stored", m5paper_weather::forecast().count);
it.printf(40, 900, id(font_small), id(ink), "ESPHome %s", ESPHOME_VERSION);
return;
}
// forecast screen
using namespace m5paper_weather;
image::Image *large[] = {id(wl_clear_night), id(wl_cloudy), id(wl_exceptional), id(wl_fog),
id(wl_hail), id(wl_lightning), id(wl_lightning_rainy), id(wl_partlycloudy),
id(wl_pouring), id(wl_rainy), id(wl_snowy), id(wl_snowy_rainy),
id(wl_sunny), id(wl_windy), id(wl_windy_variant)};
image::Image *small[] = {id(ws_clear_night), id(ws_cloudy), id(ws_exceptional), id(ws_fog),
id(ws_hail), id(ws_lightning), id(ws_lightning_rainy), id(ws_partlycloudy),
id(ws_pouring), id(ws_rainy), id(ws_snowy), id(ws_snowy_rainy),
id(ws_sunny), id(ws_windy), id(ws_windy_variant)};
static const char *const DAYS[] = {"Nedeľa", "Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota"};
const auto &fc = m5paper_weather::forecast();
const auto now = id(rtc_time).now();
// header: date, battery
if (now.is_valid())
it.printf(24, 16, id(font_date), id(ink), "%s %d. %d.", DAYS[(now.day_of_week + 6) % 7], now.day_of_month,
now.month);
if (!std::isnan(batt))
it.printf(w - 24, 22, id(font_small), id(ink), TextAlign::TOP_RIGHT, "%.0f %%", batt);
const Hour *cur = now.is_valid() ? fc.at(now.timestamp) : nullptr;
if (cur == nullptr) {
it.print(cx, 230, id(font_status), id(ink), TextAlign::CENTER, "Predpoveď nie je k dispozícii");
} else {
// current hour
if (cur->cond < NUM_CONDITIONS)
it.image(24, 80, large[cur->cond], id(ink));
if (cur->temp2 != NO_TEMP)
it.printf(200, 70, id(font_temp), id(ink), "%.0f°", cur->temp2 / 2.0f);
it.print(204, 200, id(font_status), id(ink), condition_name(cur->cond));
if (cur->wind != NO_VALUE)
it.printf(204, 245, id(font_small), id(ink), "vietor %u km/h", cur->wind);
if (cur->pop != NO_VALUE)
it.printf(204, 280, id(font_small), id(ink), "zrážky %u %%", cur->pop);
// next 24 hours: 3 columns x 8 rows
const uint32_t first = (now.timestamp / 3600 + 1) * 3600;
const int top = 440, row_h = 60, col_w = w / 3;
for (int i = 0; i < 24; i++) {
const Hour *h = fc.at(first + i * 3600);
if (h == nullptr)
break;
const int x = (i / 8) * col_w + 12;
const int y = top + (i % 8) * row_h;
const auto t = ESPTime::from_epoch_local(first + i * 3600);
it.printf(x, y + 14, id(font_hour), id(ink), "%02d", t.hour);
if (h->cond < NUM_CONDITIONS)
it.image(x + 42, y + 8, small[h->cond], id(ink));
if (h->temp2 != NO_TEMP)
it.printf(x + 94, y + 6, id(font_hour), id(ink), "%.0f°", h->temp2 / 2.0f);
if (h->pop != NO_VALUE && h->pop >= 10)
it.printf(x + 94, y + 38, id(font_tiny), id(ink), "%u %%", h->pop);
}
for (int c = 1; c < 3; c++)
it.vertical_line(c * col_w, top, 8 * row_h, id(ink));
}
// indoor
it.horizontal_line(24, 340, w - 48, id(ink));
it.printf(cx, 380, id(font_status), id(ink), TextAlign::CENTER, "Doma %.1f °C · %.0f %%", temp, hum);
it.horizontal_line(24, 420, w - 48, id(ink));
// footer
if (fc.valid()) {
const auto f = ESPTime::from_epoch_local(fc.fetched_at);
it.printf(24, 944, id(font_small), id(ink), TextAlign::BOTTOM_LEFT, "predpoveď %d. %d. %02d:%02d",
f.day_of_month, f.month, f.hour, f.minute);
}
if (now.is_valid())
it.printf(w - 24, 944, id(font_small), id(ink), TextAlign::BOTTOM_RIGHT, "%02d:%02d", now.hour, now.minute);
touchscreen:
- platform: gt911
id: gt911_touchscreen
display: m5paper_display
# GPIO36 is input-only; INT is only driven as output when reset_pin is set
interrupt_pin:
number: GPIO36
mode:
input: true
output: false