--- # Hardware: M5Stack M5Paper # Pinout: # GPIO2 -> main_power (switch gpio) # GPIO5 -> ext_power (switch gpio) # GPIO12 -> spi (spi mosi_pin) # GPIO13 -> spi (spi miso_pin) # GPIO14 -> spi (spi clk_pin) # GPIO15 -> m5paper_display (display it8951 cs_pin) # GPIO21 -> i2c_main (i2c sda) # GPIO22 -> i2c_main (i2c scl) # GPIO23 -> m5paper_display (display it8951 reset_pin) # GPIO27 -> m5paper_display (display it8951 busy_pin) # GPIO35 -> Battery Voltage (sensor adc) # GPIO36 -> gt911_touchscreen (touchscreen gt911 interrupt_pin) # GPIO37 -> M5Paper Button UP (binary_sensor gpio) (inverted) # GPIO38 -> M5Paper Button Press (binary_sensor gpio) (inverted) # GPIO39 -> M5Paper Button Down (binary_sensor gpio) (inverted) # I2C: # 0x44 sensor sht3xd (sensor sht3xd) # 0x51 rtc_time (time bm8563) # 0x5D gt911_touchscreen (touchscreen gt911) substitutions: device: m5paper name: M5Paper area: DEV comment: M5Paper waste_calendar: calendar.https_www_hviezdoslavov_sk_kalendar_icalfeed_category_0_ongoing_1 # contacts + manual birthday_calendars: calendar.birthdays, calendar.narodeniny # Power modes # - USB: always on, WiFi + API, events refreshed and redrawn after midnight # - battery: RTC wakes the board at 00:05, it refreshes the events, draws them and powers # itself off; the RTC timer lasts at most 255 min, so the wake-ups on the way to # midnight only re-arm it # The power button (and USB) also connects to WiFi: the events are refreshed on every # Home Assistant connection outside the battery cycle. # Screens: status (after power-on, until Home Assistant connects) and calendar # 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_calendar.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_calendar::load(m5paper_calendar::events()); ESP_LOGI("m5paper", "Wake-up: %s, events: %u", id(rtc_wake) ? "RTC" : "power-on", m5paper_calendar::events().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 ipv6_only: !include common/ipv6_only.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: calendar screen regardless of the connection state - id: calendar_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(calendar_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(calendar_page); then: - component.update: m5paper_display # outside the battery cycle (power button, USB) WiFi is up anyway: always refresh - if: condition: and: - lambda: return !id(calendar_page); - api.connected: state_subscription_only: true then: - script.execute: fetch_events on_client_disconnected: - if: condition: not: api.connected: state_subscription_only: true then: - lambda: id(show_status) = true; - if: condition: lambda: return !id(calendar_page); then: - component.update: m5paper_display # I2C devices (i2c_main) # 0x44 SHT30 # 0x50 FM24C02 EEPROM (unused) # 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 events if they are old, draw them, power off - id: battery_cycle mode: single then: - lambda: id(calendar_page) = true; - wait_until: condition: lambda: return id(rtc_time).now().is_valid(); timeout: 10s - if: # RTC wake-up on the way to midnight: nothing to draw condition: lambda: |- const auto now = id(rtc_time).now(); return id(rtc_wake) && now.is_valid() && now.hour != 0; then: - script.execute: try_sleep else: - if: condition: lambda: return m5paper_calendar::stale(id(rtc_time).now()); 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_events - wait_until: condition: lambda: return id(fetch_done); timeout: 15s - component.update: m5paper_display # let the e-paper refresh finish before cutting power - delay: 4s - script.execute: try_sleep # today and the next 31 days (the month grid and the list) - id: fetch_events mode: single then: - lambda: id(fetch_done) = false; - homeassistant.action: action: calendar.get_events data: entity_id: ${waste_calendar}, ${birthday_calendars} start_date_time: !lambda return id(rtc_time).now().strftime("%Y-%m-%d 00:00:00"); duration: "744:00:00" capture_response: true on_success: - lambda: |- auto &ev = m5paper_calendar::events(); if (m5paper_calendar::parse(response, "${waste_calendar}", id(rtc_time).now().timestamp, ev)) { const bool saved = m5paper_calendar::save(ev); ESP_LOGI("m5paper", "Events: %u%s", ev.count, saved ? "" : " (flash write failed)"); } else { ESP_LOGW("m5paper", "Events: unexpected response"); } id(fetch_done) = true; # the battery cycle draws on its own - if: condition: lambda: return !id(calendar_page); then: - component.update: m5paper_display on_error: - lambda: |- ESP_LOGW("m5paper", "Events: %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 00:05; the timer lasts at most 255 min, so get there in steps const int MAX = 255 * 60; int duration = MAX; const auto now = id(rtc_time).now(); if (now.is_valid()) { const int sod = now.hour * 3600 + now.minute * 60 + now.second; int left = (86400 + 300 - sod) % 86400; if (left < 600) // just drawn: the 1/60 Hz RTC timer may wake us up to a minute early left += 86400; // leave at least 20 min for the last step, so it cannot fall into the case above duration = left > MAX ? std::min(MAX, left - 1200) : left; } if (duration > 255) // longer timers count whole minutes duration = (duration + 59) / 60 * 60; id(sleep_s) = std::min(duration, MAX); 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(rtc_wake) = false; id(calendar_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(calendar_page); then: - component.update: m5paper_display on_time: # new day - seconds: 0 minutes: 0 hours: 0 then: - if: condition: lambda: return !id(show_status) && !id(calendar_page); then: - component.update: m5paper_display # USB mode: keep the stored events fresh for battery mode - seconds: 0 minutes: 5 hours: 0 then: - if: condition: and: - lambda: return id(usb_mode); - api.connected: state_subscription_only: true then: - script.execute: fetch_events - 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 Calendar entity_category: config on_press: - script.execute: fetch_events 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: &icon {platform: file, type: BINARY, transparency: chroma_key} image: - platform: file file: assets/images/made-for-esphome-white-on-black.svg id: esphome_logo resize: 460x136 type: GRAYSCALE transparency: alpha_channel # same order as m5paper_calendar::Kind - {<<: *icon, file: "mdi:cake-variant", id: icon_birthday, resize: 32x32} - {<<: *icon, file: "mdi:trash-can", id: icon_waste, resize: 32x32} - {<<: *icon, file: "mdi:cake-variant", id: mark_birthday, resize: 18x18} - {<<: *icon, file: "mdi:trash-can", id: mark_waste, resize: 18x18} 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_footer size: 18 glyphs: "0123456789abcdef:. " - file: "gfonts://Roboto@500" id: font_date size: 34 glyphs: *sk_glyphs glyphsets: [GF_Latin_Kernel] 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; // status screen after power-on, until Home Assistant connects if (id(show_status) && !id(calendar_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), "Calendar: %u events stored", m5paper_calendar::events().count); it.printf(40, 900, id(font_small), id(ink), "ESPHome %s", ESPHOME_VERSION); return; } // calendar screen using namespace m5paper_calendar; image::Image *icons[] = {id(icon_birthday), id(icon_waste)}; image::Image *marks[] = {id(mark_birthday), id(mark_waste)}; static const char *const DAYS[] = {"Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota", "Nedeľa"}; static const char *const DAYS_SHORT[] = {"Po", "Ut", "St", "Št", "Pi", "So", "Ne"}; static const char *const MONTHS[] = {"Január", "Február", "Marec", "Apríl", "Máj", "Jún", "Júl", "August", "September", "Október", "November", "December"}; const auto &ev = m5paper_calendar::events(); const auto now = id(rtc_time).now(); if (!now.is_valid()) { it.print(cx, 400, id(font_status), id(ink), TextAlign::CENTER, "Čas nie je k dispozícii"); return; } const int32_t today = local_day(now); // header: date, battery it.printf(24, 16, id(font_date), id(ink), "%s %d. %d.", DAYS[weekday(today)], 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); // month grid; days with events get the icons of their kinds const int32_t first = days_from_civil(now.year, now.month, 1); const int32_t next = now.month == 12 ? days_from_civil(now.year + 1, 1, 1) : days_from_civil(now.year, now.month + 1, 1); const int dim = next - first, lead = weekday(first); uint8_t kinds[32] = {}; for (int i = 0; i < ev.count; i++) { if (ev.items[i].day >= first && ev.items[i].day < next) kinds[ev.items[i].day - first + 1] |= 1 << ev.items[i].kind; } const int col_w = 70, row_h = 54, gx = (w - 7 * col_w) / 2, gy = 150; it.printf(cx, 70, id(font_status), id(ink), TextAlign::TOP_CENTER, "%s %d", MONTHS[now.month - 1], now.year); for (int c = 0; c < 7; c++) it.print(gx + c * col_w + col_w / 2, 116, id(font_small), id(ink), TextAlign::TOP_CENTER, DAYS_SHORT[c]); for (int d = 1; d <= dim; d++) { const int cell = lead + d - 1; const int x = gx + (cell % 7) * col_w, y = gy + (cell / 7) * row_h; const bool is_today = first + d - 1 == today; if (is_today) it.filled_rectangle(x + 3, y + 1, col_w - 6, row_h - 2, id(ink)); const Color fg = is_today ? id(paper) : id(ink); it.printf(x + col_w / 2, y + 4, id(font_small), fg, TextAlign::TOP_CENTER, "%d", d); const int n = __builtin_popcount(kinds[d]); int mx = x + (col_w - n * 20) / 2; for (int k = 0; k < 2; k++) { if (kinds[d] & (1 << k)) { it.image(mx, y + 32, marks[k], fg); mx += 20; } } } // next 14 days; waste collection today or tomorrow is inverted: put the bin out const int top = 490, list_h = 50, max_rows = 8; it.horizontal_line(24, top - 12, w - 48, id(ink)); int rows = 0, more = 0; for (int i = 0; i < ev.count; i++) { const Event &e = ev.items[i]; if (e.day < today || e.day >= today + 14) continue; if (rows == max_rows) { more++; continue; } const int y = top + rows++ * list_h; const bool urgent = e.kind == WASTE && e.day <= today + 1; if (urgent) it.filled_rectangle(16, y, w - 32, list_h - 4, id(ink)); const Color fg = urgent ? id(paper) : id(ink); const int ty = y + (list_h - 4) / 2; if (e.day == today) it.print(28, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "Dnes"); else if (e.day == today + 1) it.print(28, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "Zajtra"); else { const auto t = ESPTime::from_epoch_local(uint32_t(e.day) * 86400 + 43200); it.printf(28, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "%s %d. %d.", DAYS_SHORT[weekday(e.day)], t.day_of_month, t.month); } if (e.kind < 2) it.image(150, ty - 16, icons[e.kind], fg); it.start_clipping(194, y, w - 28, y + list_h); if (e.minute == ALL_DAY) it.print(194, ty, id(font_small), fg, TextAlign::CENTER_LEFT, e.summary); else it.printf(194, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "%02d:%02d %s", e.minute / 60, e.minute % 60, e.summary); it.end_clipping(); } if (!ev.valid()) it.print(cx, top + 20, id(font_small), id(ink), TextAlign::TOP_CENTER, "Kalendár nie je k dispozícii"); else if (rows == 0) it.print(cx, top + 20, id(font_small), id(ink), TextAlign::TOP_CENTER, "Žiadne udalosti v najbližších 14 dňoch"); else if (more > 0) it.printf(w - 28, top + rows * list_h, id(font_small), id(ink), TextAlign::TOP_RIGHT, "+%d ďalšie", more); // footer: IPv6 address (only while WiFi is up), last download it.horizontal_line(24, 910, w - 48, id(ink)); char buf[network::IP_ADDRESS_BUFFER_SIZE]; for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) { if (!ip.is_set() || !ip.is_ip6()) continue; ip.str_to(buf); if (strncmp(buf, "fe80", 4) != 0) { it.print(24, 944, id(font_footer), id(ink), TextAlign::BOTTOM_LEFT, buf); break; } } if (ev.valid()) { const auto f = ESPTime::from_epoch_local(ev.fetched_at); it.printf(w - 24, 944, id(font_footer), id(ink), TextAlign::BOTTOM_RIGHT, "%d. %d. %02d:%02d", f.day_of_month, f.month, f.hour, f.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