--- 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, ®, 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