From dd508aec990698234c59ac4b82fb2552311621f7 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Mon, 28 Sep 2026 23:40:13 +0200 Subject: [PATCH] m5paper: replace the weather forecast with a calendar (birthdays, waste collection), wake up once a day, IPv6-only --- include/m5paper_calendar.h | 160 ++++++++++++++++ include/m5paper_weather.h | 179 ------------------ mcu-m5paper.yaml | 375 +++++++++++++++++++------------------ 3 files changed, 354 insertions(+), 360 deletions(-) create mode 100644 include/m5paper_calendar.h delete mode 100644 include/m5paper_weather.h diff --git a/include/m5paper_calendar.h b/include/m5paper_calendar.h new file mode 100644 index 0000000..edf1089 --- /dev/null +++ b/include/m5paper_calendar.h @@ -0,0 +1,160 @@ +#pragma once +// Upcoming calendar events for mcu-m5paper: parsed from the calendar.get_events +// response and kept in flash (NVS), so they survive the power-off between RTC wake-ups. + +#include +#include +#include + +#include "esphome/components/json/json_util.h" +#include "esphome/core/preferences.h" +#include "esphome/core/time.h" + +namespace m5paper_calendar { + +static constexpr uint8_t MAX_EVENTS = 32; +static constexpr size_t SUMMARY_LEN = 44; +static constexpr uint16_t ALL_DAY = 0xFFFF; +static constexpr uint32_t PREF_HASH = 0x6D35C41A; + +enum Kind : uint8_t { BIRTHDAY = 0, WASTE }; + +struct Event { + uint16_t day; // local date, days since 1970-01-01 + uint16_t minute; // local start time in minutes after midnight, ALL_DAY if none + uint8_t kind; + char summary[SUMMARY_LEN + 1]; +}; + +struct Events { + uint32_t fetched_at{0}; // UTC epoch of the download + uint8_t count{0}; + Event items[MAX_EVENTS]{}; + + bool valid() const { return this->fetched_at != 0; } +}; + +// the events in RAM, loaded from flash at boot +inline Events &events() { + static Events ev; + return ev; +} + +// 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; +} + +inline int32_t local_day(const esphome::ESPTime &t) { return days_from_civil(t.year, t.month, t.day_of_month); } + +// 0 = Monday +inline int weekday(int32_t day) { return (day + 3) % 7; } + +// "2026-10-01" (all-day) or "2026-10-01T10:00:00+02:00" -> local day and minute; false on error +inline bool parse_start(const char *s, uint16_t &day, uint16_t &minute) { + 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 < 3) + return false; + if (n < 5) { + day = days_from_civil(y, mo, d); + minute = ALL_DAY; + return true; + } + 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; + } + if (ts <= 0) + return false; + const auto t = esphome::ESPTime::from_epoch_local(ts); + day = local_day(t); + minute = t.hour * 60 + t.minute; + return true; +} + +// copy at most SUMMARY_LEN bytes without cutting a UTF-8 sequence +inline void copy_summary(char *dst, const char *src) { + size_t n = strnlen(src, SUMMARY_LEN + 1); + if (n > SUMMARY_LEN) { + n = SUMMARY_LEN; + while (n > 0 && (uint8_t(src[n]) & 0xC0) == 0x80) + n--; + } + memcpy(dst, src, n); + dst[n] = '\0'; +} + +// Parse the calendar.get_events response; Home Assistant wraps the action response as +// {"response": {"calendar.x": {"events": [...]}, ...}}; every calendar other than the waste one holds birthdays +inline bool parse(ArduinoJson::JsonObjectConst response, const char *waste, uint32_t now, Events &ev) { + ArduinoJson::JsonObjectConst root = response["response"].is() + ? response["response"].as() + : response; + if (root.size() == 0) + return false; + + Events out; + for (ArduinoJson::JsonPairConst cal : root) { + const uint8_t kind = strcmp(cal.key().c_str(), waste) == 0 ? WASTE : BIRTHDAY; + for (ArduinoJson::JsonObjectConst item : cal.value()["events"].as()) { + Event e; + if (!parse_start(item["start"] | "", e.day, e.minute)) + continue; + e.kind = kind; + copy_summary(e.summary, item["summary"] | ""); + // the same birthday in the contacts and the manual calendar + if (std::any_of(out.items, out.items + out.count, + [&e](const Event &o) { return o.day == e.day && strcmp(o.summary, e.summary) == 0; })) + continue; + if (out.count < MAX_EVENTS) { + out.items[out.count++] = e; + continue; + } + // full: keep the nearest events + Event *last = std::max_element(out.items, out.items + out.count, + [](const Event &a, const Event &b) { return a.day < b.day; }); + if (e.day < last->day) + *last = e; + } + } + std::sort(out.items, out.items + out.count, [](const Event &a, const Event &b) { + // all-day (0xFFFF wraps to 0) first + return a.day != b.day ? a.day < b.day : uint16_t(a.minute + 1) < uint16_t(b.minute + 1); + }); + out.fetched_at = now; + ev = out; + return true; +} + +// battery cycle: refreshed once a day, after midnight +inline bool stale(const esphome::ESPTime &now) { + const auto &ev = events(); + return !ev.valid() || !now.is_valid() || now.timestamp - ev.fetched_at >= 20 * 3600; +} + +inline esphome::ESPPreferenceObject &pref() { + static esphome::ESPPreferenceObject p = esphome::global_preferences->make_preference(PREF_HASH, true); + return p; +} + +inline bool load(Events &ev) { + if (!pref().load(&ev)) { + ev = Events{}; + return false; + } + ev.count = std::min(ev.count, MAX_EVENTS); + return true; +} + +// written to flash right away: the board may power off before the next preferences flush +inline bool save(const Events &ev) { return pref().save(&ev) && esphome::global_preferences->sync(); } + +} // namespace m5paper_calendar diff --git a/include/m5paper_weather.h b/include/m5paper_weather.h deleted file mode 100644 index 5db99ea..0000000 --- a/include/m5paper_weather.h +++ /dev/null @@ -1,179 +0,0 @@ -#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"] | ""); - // OpenWeatherMap reports "sunny" for clear nights too; no UV means the sun is down - if (hour.cond == condition_code("sunny") && (item["uv_index"] | 1.0f) == 0.0f) - hour.cond = condition_code("clear-night"); - 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 4c3dafb..e28efe6 100644 --- a/mcu-m5paper.yaml +++ b/mcu-m5paper.yaml @@ -4,13 +4,18 @@ substitutions: name: M5Paper area: DEV comment: M5Paper - weather_entity: weather.openweathermap + 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, 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 +# - 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: @@ -19,7 +24,7 @@ esphome: area: ${area} comment: ${comment} includes: - - include/m5paper_weather.h + - include/m5paper_calendar.h on_boot: - priority: 600 then: @@ -33,8 +38,8 @@ esphome: 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); + 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: @@ -65,6 +70,7 @@ psram: packages: common: !include common/common.yaml + ipv6_only: !include common/ipv6_only.yaml globals: - id: show_status @@ -76,8 +82,8 @@ globals: - id: rtc_wake type: bool initial_value: 'false' - # battery cycle running: forecast screen regardless of the connection state - - id: weather_page + # battery cycle running: calendar screen regardless of the connection state + - id: calendar_page type: bool initial_value: 'false' - id: fetch_done @@ -92,7 +98,7 @@ wifi: on_connect: - if: condition: - lambda: return id(show_status) && !id(weather_page); + lambda: return id(show_status) && !id(calendar_page); then: - component.update: m5paper_display @@ -108,21 +114,18 @@ api: - lambda: id(show_status) = false; - if: condition: - lambda: return !id(weather_page); + lambda: return !id(calendar_page); then: - component.update: m5paper_display - # outside the battery cycle: refresh a forecast older than an hour + # outside the battery cycle (power button, USB) WiFi is up anyway: always refresh - 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); + - lambda: return !id(calendar_page); - api.connected: state_subscription_only: true then: - - script.execute: fetch_forecast + - script.execute: fetch_events on_client_disconnected: - if: condition: @@ -133,13 +136,13 @@ api: - lambda: id(show_status) = true; - if: condition: - lambda: return !id(weather_page); + lambda: return !id(calendar_page); then: - component.update: m5paper_display # I2C devices (i2c_main) # 0x44 SHT30 -# 0x50 FM24C02 EEPROM (forecast storage, see include/m5paper_weather.h) +# 0x50 FM24C02 EEPROM (unused) # 0x51 BM8563 RTC # 0x5D GT911 touch (docs say 0x14; the driver tries 0x5D, then 0x14) @@ -185,76 +188,80 @@ switch: internal: true script: - # One wake-up on battery: refresh the forecast if it is old, draw it, power off + # One wake-up on battery: refresh the events if they are old, draw them, power off - id: battery_cycle mode: single then: - - lambda: id(weather_page) = true; + - 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 &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; + return id(rtc_wake) && now.is_valid() && now.hour != 0; then: - - wifi.enable: - - wait_until: - condition: - api.connected: - state_subscription_only: true - timeout: 45s + - script.execute: try_sleep + else: - if: condition: - api.connected: - state_subscription_only: true + lambda: return m5paper_calendar::stale(id(rtc_time).now()); then: - - script.execute: fetch_forecast + - wifi.enable: - 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 + 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 - - id: fetch_forecast + # 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: weather.get_forecasts + action: calendar.get_events data: - entity_id: ${weather_entity} - type: hourly + 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 &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)"); + 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", "Forecast: unexpected response"); + ESP_LOGW("m5paper", "Events: unexpected response"); } id(fetch_done) = true; # the battery cycle draws on its own - if: condition: - lambda: return !id(weather_page); + lambda: return !id(calendar_page); then: - component.update: m5paper_display on_error: - lambda: |- - ESP_LOGW("m5paper", "Forecast: %s", error.c_str()); + 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 @@ -262,22 +269,21 @@ script: mode: single then: - lambda: |- - // next quarter hour, 1:00-5:00 in one go - int duration = 900; + // 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 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; + 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, 255 * 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); @@ -292,7 +298,8 @@ script: - lambda: |- ESP_LOGI("m5paper", "Still running, USB power"); id(usb_mode) = true; - id(weather_page) = false; + 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 @@ -321,21 +328,24 @@ time: - bm8563.write_time - if: condition: - lambda: return !id(weather_page); + lambda: return !id(calendar_page); then: - component.update: m5paper_display on_time: + # new day - seconds: 0 - minutes: /15 + minutes: 0 + hours: 0 then: - if: condition: - lambda: return !id(show_status) && !id(weather_page); + lambda: return !id(show_status) && !id(calendar_page); then: - component.update: m5paper_display - # USB mode: keep the stored forecast fresh for battery mode + # USB mode: keep the stored events fresh for battery mode - seconds: 0 minutes: 5 + hours: 0 then: - if: condition: @@ -344,7 +354,7 @@ time: - api.connected: state_subscription_only: true then: - - script.execute: fetch_forecast + - script.execute: fetch_events - platform: bm8563 id: rtc_time @@ -388,10 +398,10 @@ button: on_press: - component.update: m5paper_display - platform: template - name: Refresh Forecast + name: Refresh Calendar entity_category: config on_press: - - script.execute: fetch_forecast + - script.execute: fetch_events sensor: - platform: sht3xd @@ -429,8 +439,7 @@ sensor: 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} + icon: &icon {platform: file, type: BINARY, transparency: chroma_key} image: - platform: file @@ -439,37 +448,11 @@ image: 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} + # 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" @@ -486,22 +469,14 @@ font: glyphs: *sk_glyphs glyphsets: [GF_Latin_Kernel] - file: "gfonts://Roboto" - id: font_tiny + id: font_footer size: 18 - glyphs: "0123456789% " + glyphs: "0123456789abcdef:. " - 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 @@ -528,11 +503,9 @@ display: 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)) { + 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}"); @@ -561,85 +534,125 @@ display: 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, 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; } - // 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"}; + // 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 &fc = m5paper_weather::forecast(); + 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 - 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); + 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); - 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)); - // temperature in line with the hour, lifted only to make room for the precipitation below - const bool show_pop = h->pop != NO_VALUE && h->pop >= 10; - if (h->temp2 != NO_TEMP) - it.printf(x + 94, show_pop ? y + 6 : y + 14, id(font_hour), id(ink), "%.0f°", h->temp2 / 2.0f); - if (show_pop) - it.printf(x + 94, y + 38, id(font_tiny), id(ink), "%u %%", h->pop); + // 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; + } } - 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)); + // 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 - 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); + // 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); } - 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