From 329830e55325be63cc03bd0ab35257219d08b9a3 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Sun, 27 Sep 2026 19:49:48 +0200 Subject: [PATCH] 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 --- .../made-for-esphome-black-on-white.svg | 1 + .../made-for-esphome-white-on-black.svg | 1 + include/m5paper_weather.h | 176 +++++ mcu-m5paper.yaml | 639 ++++++++++++++++-- 4 files changed, 742 insertions(+), 75 deletions(-) create mode 100644 assets/images/made-for-esphome-black-on-white.svg create mode 100644 assets/images/made-for-esphome-white-on-black.svg create mode 100644 include/m5paper_weather.h 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