diff --git a/assets/images/made-for-esphome-black-on-white.svg b/assets/images/made-for-esphome-black-on-white.svg
new file mode 100644
index 0000000..5b7bedb
--- /dev/null
+++ b/assets/images/made-for-esphome-black-on-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/made-for-esphome-white-on-black.svg b/assets/images/made-for-esphome-white-on-black.svg
new file mode 100644
index 0000000..37c5ccc
--- /dev/null
+++ b/assets/images/made-for-esphome-white-on-black.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/include/m5paper_weather.h b/include/m5paper_weather.h
new file mode 100644
index 0000000..8bfbff5
--- /dev/null
+++ b/include/m5paper_weather.h
@@ -0,0 +1,176 @@
+#pragma once
+// Hourly weather forecast for mcu-m5paper: parsed from the weather.get_forecasts
+// response and kept in the on-board FM24C02 EEPROM (256 B, I2C 0x50), so it
+// survives the power-off between RTC wake-ups.
+
+#include
+#include
+#include
+
+#include "esphome/components/i2c/i2c_bus.h"
+#include "esphome/components/json/json_util.h"
+#include "esphome/core/hal.h"
+
+namespace m5paper_weather {
+
+static constexpr uint8_t EEPROM_ADDR = 0x50;
+static constexpr uint8_t EEPROM_PAGE = 8;
+static constexpr uint8_t MAGIC = 0xA5;
+static constexpr uint8_t VERSION = 1;
+static constexpr uint8_t MAX_HOURS = 48;
+// magic, version, fetched_at (u32), first_ts (u32), count
+static constexpr size_t HEADER = 11;
+static constexpr size_t MAX_SIZE = HEADER + MAX_HOURS * 4;
+static_assert(MAX_SIZE <= 256, "forecast does not fit into the EEPROM");
+
+static constexpr uint8_t NO_VALUE = 0xFF;
+static constexpr int8_t NO_TEMP = INT8_MIN;
+
+struct Hour {
+ uint8_t cond; // index into CONDITIONS, NO_VALUE if unknown
+ int8_t temp2; // temperature in 0.5 °C, NO_TEMP if unknown
+ uint8_t wind; // km/h, NO_VALUE if unknown
+ uint8_t pop; // precipitation probability %, NO_VALUE if unknown
+};
+static_assert(sizeof(Hour) == 4, "Hour must stay 4 bytes");
+
+struct Forecast {
+ uint32_t fetched_at{0}; // UTC epoch of the download
+ uint32_t first_ts{0}; // UTC epoch of hours[0]; entries are 1 h apart
+ uint8_t count{0};
+ Hour hours[MAX_HOURS]{};
+
+ bool valid() const { return this->count > 0; }
+ // hour covering the given UTC epoch, nullptr if outside the forecast
+ const Hour *at(uint32_t ts) const {
+ if (!this->valid() || ts < this->first_ts)
+ return nullptr;
+ uint32_t idx = (ts - this->first_ts) / 3600;
+ return idx < this->count ? &this->hours[idx] : nullptr;
+ }
+};
+
+// the forecast in RAM, loaded from the EEPROM at boot
+inline Forecast &forecast() {
+ static Forecast fc;
+ return fc;
+}
+
+// Home Assistant weather conditions; the index is the stored condition code
+static const char *const CONDITIONS[] = {
+ "clear-night", "cloudy", "exceptional", "fog", "hail", "lightning", "lightning-rainy", "partlycloudy",
+ "pouring", "rainy", "snowy", "snowy-rainy", "sunny", "windy", "windy-variant",
+};
+static const char *const CONDITION_NAMES[] = {
+ "Jasno", "Oblačno", "Extrémne počasie", "Hmla", "Krupobitie", "Búrka", "Búrka s dažďom", "Polooblačno",
+ "Lejak", "Dážď", "Sneženie", "Dážď so snehom", "Slnečno", "Veterno", "Veterno, oblačno",
+};
+static constexpr uint8_t NUM_CONDITIONS = sizeof(CONDITIONS) / sizeof(CONDITIONS[0]);
+
+inline uint8_t condition_code(const char *name) {
+ for (uint8_t i = 0; i < NUM_CONDITIONS; i++) {
+ if (strcmp(name, CONDITIONS[i]) == 0)
+ return i;
+ }
+ return NO_VALUE;
+}
+
+inline const char *condition_name(uint8_t code) { return code < NUM_CONDITIONS ? CONDITION_NAMES[code] : "?"; }
+
+// days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm)
+inline int32_t days_from_civil(int y, int m, int d) {
+ y -= m <= 2;
+ const int era = (y >= 0 ? y : y - 399) / 400;
+ const int yoe = y - era * 400;
+ const int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
+ const int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ return era * 146097 + doe - 719468;
+}
+
+// "2026-09-27T16:00:00+00:00" -> UTC epoch, 0 on error
+inline uint32_t parse_iso8601(const char *s) {
+ int y, mo, d, h, mi, sec = 0, oh = 0, om = 0;
+ char sign = '+';
+ int n = sscanf(s, "%d-%d-%dT%d:%d:%d%c%d:%d", &y, &mo, &d, &h, &mi, &sec, &sign, &oh, &om);
+ if (n < 5)
+ return 0;
+ int64_t ts = int64_t(days_from_civil(y, mo, d)) * 86400 + h * 3600 + mi * 60 + sec;
+ if (n >= 8 && (sign == '+' || sign == '-')) {
+ int64_t offset = oh * 3600 + om * 60;
+ ts += sign == '+' ? -offset : offset;
+ }
+ return ts > 0 ? uint32_t(ts) : 0;
+}
+
+// Parse the weather.get_forecasts response for the given entity;
+// Home Assistant wraps the action response as {"response": {...}}
+inline bool parse(ArduinoJson::JsonObjectConst response, const char *entity, uint32_t now, Forecast &fc) {
+ ArduinoJson::JsonObjectConst root = response["response"].is()
+ ? response["response"].as()
+ : response;
+ ArduinoJson::JsonArrayConst items = root[entity]["forecast"];
+ if (items.isNull() || items.size() == 0)
+ return false;
+
+ Forecast out;
+ for (ArduinoJson::JsonObjectConst item : items) {
+ if (out.count >= MAX_HOURS)
+ break;
+ if (out.count == 0) {
+ out.first_ts = parse_iso8601(item["datetime"] | "");
+ if (out.first_ts == 0)
+ return false;
+ }
+ Hour &hour = out.hours[out.count++];
+ hour.cond = condition_code(item["condition"] | "");
+ float temp = item["temperature"] | NAN;
+ hour.temp2 = std::isnan(temp) ? NO_TEMP : int8_t(std::max(-127L, std::min(127L, lroundf(temp * 2))));
+ float wind = item["wind_speed"] | NAN;
+ hour.wind = std::isnan(wind) ? NO_VALUE : uint8_t(std::max(0L, std::min(254L, lroundf(wind))));
+ float pop = item["precipitation_probability"] | NAN;
+ hour.pop = std::isnan(pop) ? NO_VALUE : uint8_t(std::max(0L, std::min(100L, lroundf(pop))));
+ }
+ out.fetched_at = now;
+ fc = out;
+ return true;
+}
+
+inline bool load(esphome::i2c::I2CBus *bus, Forecast &fc) {
+ uint8_t buf[MAX_SIZE];
+ const uint8_t start = 0;
+ fc.count = 0;
+ if (bus->write_readv(EEPROM_ADDR, &start, 1, buf, sizeof(buf)) != esphome::i2c::ERROR_OK)
+ return false;
+ if (buf[0] != MAGIC || buf[1] != VERSION)
+ return false;
+ memcpy(&fc.fetched_at, buf + 2, 4);
+ memcpy(&fc.first_ts, buf + 6, 4);
+ fc.count = std::min(buf[10], MAX_HOURS);
+ memcpy(fc.hours, buf + HEADER, fc.count * sizeof(Hour));
+ return true;
+}
+
+inline bool save(esphome::i2c::I2CBus *bus, const Forecast &fc) {
+ uint8_t buf[MAX_SIZE];
+ buf[0] = MAGIC;
+ buf[1] = VERSION;
+ memcpy(buf + 2, &fc.fetched_at, 4);
+ memcpy(buf + 6, &fc.first_ts, 4);
+ buf[10] = fc.count;
+ memcpy(buf + HEADER, fc.hours, fc.count * sizeof(Hour));
+ const size_t len = HEADER + fc.count * sizeof(Hour);
+
+ // page writes must not cross an 8-byte page boundary; 5 ms write cycle per page
+ for (size_t off = 0; off < len; off += EEPROM_PAGE) {
+ uint8_t page[EEPROM_PAGE + 1];
+ const size_t n = std::min(EEPROM_PAGE, len - off);
+ page[0] = uint8_t(off);
+ memcpy(page + 1, buf + off, n);
+ if (bus->write(EEPROM_ADDR, page, n + 1) != esphome::i2c::ERROR_OK)
+ return false;
+ esphome::delay(6);
+ }
+ return true;
+}
+
+} // namespace m5paper_weather
diff --git a/mcu-m5paper.yaml b/mcu-m5paper.yaml
index 697807e..312f9c7 100644
--- a/mcu-m5paper.yaml
+++ b/mcu-m5paper.yaml
@@ -4,92 +4,370 @@ substitutions:
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, ®, 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:
-external_components:
- - source: github://Passific/m5paper_esphome
-
packages:
common: !include common/common.yaml
-time:
- - id: !extend ha_time
- on_time_sync:
- - bm8563.write_time
- - platform: bm8563
- id: rtc_time
- sleep_duration: 3600000ms
+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'
-m5paper:
- battery_power_pin: GPIO5
- main_power_pin: GPIO2
- # update_interval: 10s
- # battery_voltage:
- # name: "${name} Battery"
+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
-# 0x51
-# 0x5D GT911
+# 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 #false
+ scan: true
+ # Port A
# - id: i2c_a
# sda: 25
# scl: 32
- # scan: true #false
+ # Port B
# - id: i2c_b
# sda: 26
# scl: 33
- # scan: true #false
+ # Port C
# - id: i2c_c
# sda: 18
# scl: 19
- # scan: true #false
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: 37
+ number: GPIO37
inverted: true
on_press:
- component.update: m5paper_display
- on_release:
- - component.update: m5paper_display
- platform: gpio
name: ${name} Button Press
id: button_press
pin:
- number: 38
+ number: GPIO38
inverted: true
- platform: gpio
name: ${name} Button Down
id: button_down
pin:
- number: 39
+ number: GPIO39
inverted: true
- platform: touchscreen
name: ${name} Top Left Touch Button
@@ -101,51 +379,19 @@ binary_sensor:
y_min: 0
y_max: 100
on_press:
- # Example of applying the sleep duration and shutting down to the lowest power mode
- - bm8563.apply_sleep_duration
- - delay: 1s
- - m5paper.shutdown_main_power
+ - script.execute: battery_cycle
-font:
- - file: "gfonts://Roboto"
- id: font_default
- size: 20
- - file: "gfonts://Roboto"
- id: roboto_32
- size: 32
- - file: "gfonts://Roboto"
- id: roboto_24
- size: 24
- - file: "gfonts://Roboto"
- id: roboto_12
- size: 12
-
-display:
- - platform: it8951e
- id: m5paper_display
- cs_pin: GPIO15
- reset_pin: GPIO23
- busy_pin: GPIO27
- rotation: 90
- reversed: False
- update_interval: "never"
- lambda: |-
- it.printf(25, 25, id(roboto_32), "%.1f°", id(current_temperature).state);
- it.strftime(350, 25, id(roboto_32), "%H:%M:%S", id(rtc_time).now());
-
-touchscreen:
- - platform: gt911
- display: m5paper_display
- id: gt911_touchscreen
- interrupt_pin: GPIO36
-
-# display:
-# - platform: waveshare_epaper
-# cs_pin: 15
-# model: 2.90in
-# full_update_every: 30
-# lambda: |-
-# it.print(0, 0, id(font1), "Hello World!");
+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
@@ -154,9 +400,252 @@ sensor:
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
-# touchscreen:
-# - platform: gt911
-# interrupt_pin: 36
+.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