From 3b7c30634e4d42cf18a7c2f173da954c6f50fce5 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 11 Jul 2026 21:41:21 +0200 Subject: [PATCH] Add compact calendar feed to e-paper display + HA --- configuration.yaml | 3 + esphome/epaperframe.yaml | 172 +++++++++++++++++++++++++ python_scripts/epaper_calendar_data.py | 84 ++++++++++++ template.yaml | 30 +++++ 4 files changed, 289 insertions(+) create mode 100644 python_scripts/epaper_calendar_data.py diff --git a/configuration.yaml b/configuration.yaml index 56cfcf1..a1e04cc 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -35,6 +35,9 @@ utility_meter: !include utility_meters.yaml mqtt: !include mqtt.yaml mqtt_statestream: !include mqtt_statestream.yaml +# Calendar data is normalised in a sandboxed script before ESPHome receives it. +python_script: + # Template sensors template: !include template.yaml diff --git a/esphome/epaperframe.yaml b/esphome/epaperframe.yaml index 51472c8..da9ec46 100644 --- a/esphome/epaperframe.yaml +++ b/esphome/epaperframe.yaml @@ -212,6 +212,12 @@ text_sensor: entity_id: weather.zuhause id: weather + # Display-ready JSON produced by the Home Assistant calendar template sensor. + - platform: homeassistant + entity_id: sensor.epaper_kalenderdaten + attribute: entries + id: calendar_entries + time: @@ -1083,3 +1089,169 @@ display: ESP_LOGI("WiFi", "Unlikely"); } } + + - id: calendar + lambda: |- + auto now = id(homeassistant_time).now(); + const char *months[] = {"Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember"}; + const char *weekdays[] = {"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}; + + const int year = now.year; + const int month = now.month; + const int today = now.day_of_month; + + auto days_in_month = [](int calendar_year, int calendar_month) { + const int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (calendar_month == 2) { + return (calendar_year % 4 == 0 && (calendar_year % 100 != 0 || calendar_year % 400 == 0)) ? 29 : 28; + } + return days[calendar_month - 1]; + }; + auto weekday_monday = [](int calendar_year, int calendar_month, int day) { + const int offsets[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; + calendar_year -= calendar_month < 3; + const int sunday_first = (calendar_year + calendar_year / 4 - calendar_year / 100 + + calendar_year / 400 + offsets[calendar_month - 1] + day) % 7; + return (sunday_first + 6) % 7; + }; + auto iso_weeks_in_year = [&weekday_monday](int calendar_year) { + // 28 December is always in the final ISO week of its year. + const int day_of_year = 362 + ((calendar_year % 4 == 0 && + (calendar_year % 100 != 0 || calendar_year % 400 == 0)) ? 1 : 0); + const int weekday = weekday_monday(calendar_year, 12, 28) + 1; + return (day_of_year - weekday + 10) / 7; + }; + auto iso_week = [&weekday_monday, &days_in_month, &iso_weeks_in_year](int calendar_year, int calendar_month, int day) { + int day_of_year = day; + for (int current_month = 1; current_month < calendar_month; current_month++) { + day_of_year += days_in_month(calendar_year, current_month); + } + const int weekday = weekday_monday(calendar_year, calendar_month, day) + 1; + int week = (day_of_year - weekday + 10) / 7; + if (week < 1) return iso_weeks_in_year(calendar_year - 1); + if (week > iso_weeks_in_year(calendar_year)) return 1; + return week; + }; + + /* LEFT PANE: agenda */ + it.image(10, 10, id(c1024_logo)); + it.print(100, 27, id(roboto_med_30), TextAlign::BASELINE_LEFT, "TERMINE"); + it.line(10, 55, 355, 55); + it.line(365, 10, 365, 352); + + DynamicJsonDocument calendar_doc(3072); + const DeserializationError calendar_error = deserializeJson(calendar_doc, id(calendar_entries).state.c_str()); + JsonArray calendar_days = calendar_doc.as(); + int agenda_y = 72; + bool has_events = false; + bool has_hidden_events = false; + + if (calendar_error || calendar_days.isNull()) { + it.print(18, agenda_y, id(sensor_unit), TextAlign::TOP_LEFT, "Kalenderdaten werden geladen ..."); + } else if (calendar_days.size() == 0) { + it.print(18, agenda_y, id(sensor_unit), TextAlign::TOP_LEFT, "Keine Termine in den nächsten Tagen."); + } else { + for (JsonVariant day_variant : calendar_days) { + JsonObject day = day_variant.as(); + JsonArray events = day["events"].as(); + if (events.isNull() || events.size() == 0) continue; + if (agenda_y + 38 > 345) { + has_hidden_events = true; + break; + } + + const int day_number = day["day"] | 0; + const char *weekday = day["weekday"] | ""; + const bool is_today = (day["today"] | 0) == 1; + it.printf(30, agenda_y, id(sub_sensor_font), TextAlign::TOP_CENTER, "%d", day_number); + it.printf(30, agenda_y + 28, id(footer_font), TextAlign::TOP_CENTER, "%s", weekday); + it.print(68, agenda_y + 2, id(footer_font), TextAlign::TOP_LEFT, is_today ? "HEUTE" : ""); + it.line(58, agenda_y, 58, agenda_y + 32 + events.size() * 35); + agenda_y += 36; + + for (JsonVariant event_variant : events) { + if (agenda_y + 33 > 345) { + has_hidden_events = true; + break; + } + JsonObject event = event_variant.as(); + const char *title = event["title"] | "Ohne Titel"; + const char *time = event["time"] | ""; + const char *meta = event["meta"] | ""; + it.printf(72, agenda_y, id(sensor_unit), TextAlign::TOP_LEFT, "%s", title); + it.printf(350, agenda_y, id(sensor_unit), TextAlign::TOP_RIGHT, "%s", time); + it.printf(72, agenda_y + 20, id(footer_font), TextAlign::TOP_LEFT, "%s", meta); + agenda_y += 35; + has_events = true; + } + agenda_y += 5; + if (has_hidden_events) break; + } + } + + if (!has_events && !calendar_error && !calendar_days.isNull() && calendar_days.size() > 0) { + it.print(18, 72, id(sensor_unit), TextAlign::TOP_LEFT, "Keine Termine im sichtbaren Bereich."); + } + if (has_hidden_events) { + it.print(72, 340, id(footer_font), TextAlign::TOP_LEFT, "Weitere Termine vorhanden ..."); + } + + /* RIGHT TOP: month view, Monday-first with ISO week numbers */ + char month_title[24]; + snprintf(month_title, sizeof(month_title), "%s %d", months[month - 1], year); + it.printf(502, 18, id(sensor_unit), TextAlign::TOP_CENTER, "%s", month_title); + it.line(375, 28, 630, 28); + it.print(384, 47, id(footer_font), TextAlign::TOP_CENTER, "KW"); + for (int column = 0; column < 7; column++) { + it.printf(415 + column * 30, 47, id(footer_font), TextAlign::TOP_CENTER, "%s", weekdays[column]); + } + + const int first_weekday = weekday_monday(year, month, 1); + const int month_days = days_in_month(year, month); + for (int row = 0; row < 6; row++) { + const int monday_day = 1 - first_weekday + row * 7; + int week_year = year; + int week_month = month; + int week_day = monday_day; + if (week_day < 1) { + week_month--; + if (week_month == 0) { + week_month = 12; + week_year--; + } + week_day += days_in_month(week_year, week_month); + } else if (week_day > month_days) { + week_day -= month_days; + week_month++; + if (week_month == 13) { + week_month = 1; + week_year++; + } + } + const int cell_y = 69 + row * 23; + it.printf(384, cell_y, id(footer_font), TextAlign::TOP_CENTER, "%02d", iso_week(week_year, week_month, week_day)); + + for (int column = 0; column < 7; column++) { + const int day = row * 7 + column - first_weekday + 1; + if (day < 1 || day > month_days) continue; + const int cell_x = 415 + column * 30; + if (day == today) { + it.filled_rectangle(cell_x - 12, cell_y - 2, 25, 18, Color::BLACK); + it.printf(cell_x, cell_y, id(footer_font), Color::WHITE, TextAlign::TOP_CENTER, "%d", day); + } else { + it.printf(cell_x, cell_y, id(footer_font), TextAlign::TOP_CENTER, "%d", day); + } + } + } + + /* RIGHT BOTTOM: quote placeholder, intentionally inverted */ + it.filled_rectangle(375, 220, 255, 130, Color::BLACK); + it.print(502, 234, id(footer_font), Color::WHITE, TextAlign::TOP_CENTER, "DEMOTIVATION"); + it.print(502, 267, id(sensor_unit), Color::WHITE, TextAlign::TOP_CENTER, "Es ist nie zu spät,"); + it.print(502, 291, id(sensor_unit), Color::WHITE, TextAlign::TOP_CENTER, "die Erwartungen zu senken."); + it.print(502, 326, id(footer_font), Color::WHITE, TextAlign::TOP_CENTER, "Zitatquelle folgt"); + + /* FOOTER */ + it.strftime(614, 380, id(footer_font), TextAlign::BASELINE_RIGHT, + "Aktualisiert um %d.%m.%Y %H:%M", now); diff --git a/python_scripts/epaper_calendar_data.py b/python_scripts/epaper_calendar_data.py new file mode 100644 index 0000000..222e670 --- /dev/null +++ b/python_scripts/epaper_calendar_data.py @@ -0,0 +1,84 @@ +# Calendar data is compacted here so the ESP32 only receives display-ready entries. +CALENDAR_NAMES = { + "calendar.tkrz_kalender": "Arbeit", + "calendar.privat": "Privat", +} +DAY_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] +MAX_ENTRIES = 8 +MAX_TITLE_LENGTH = 28 +MAX_META_LENGTH = 32 + + +def shorten(value, maximum): + value = (value or "").strip() + if len(value) <= maximum: + return value + return value[: maximum - 3].rstrip() + "..." + + +def compact_event(event, calendar_name): + start = event.get("start", "") + all_day = "T" not in start + time = "ganztägig" if all_day else start[11:16] + + location = (event.get("location") or "").split("\n")[0].strip() + meta = calendar_name + if location: + meta += " / " + location + + return { + "title": shorten(event.get("summary", "Ohne Titel"), MAX_TITLE_LENGTH), + "time": time, + "meta": shorten(meta, MAX_META_LENGTH), + "all_day": int(all_day), + } + + +calendar_data = data.get("calendar", {}) +today = str(data.get("now", ""))[:10] +events_by_date = {} + +for calendar_id, calendar in calendar_data.items(): + calendar_name = CALENDAR_NAMES.get(calendar_id, calendar_id.split(".")[-1].capitalize()) + for event in calendar.get("events", []): + start = event.get("start", "") + end = event.get("end", "") + event_date = start[:10] + end_date = end[:10] + + # Ignore malformed or already finished entries. All-day event end dates + # are exclusive in Home Assistant; timed events ending today may still be + # active. Multi-day entries remain visible on today's date rail. + all_day = "T" not in start + if not event_date or (all_day and end_date <= today) or (not all_day and end_date < today): + continue + if event_date < today: + event_date = today + + events_by_date.setdefault(event_date, []).append( + compact_event(event, calendar_name) + ) + +entries = [] +entry_count = 0 +for date in sorted(events_by_date): + if entry_count >= MAX_ENTRIES: + break + + events = events_by_date[date] + events.sort(key=lambda item: (item["all_day"] == 0, item["time"])) + remaining = MAX_ENTRIES - entry_count + events = events[:remaining] + entry_count += len(events) + + parsed_date = dt_util.parse_datetime(date) + entries.append( + { + "day": parsed_date.day, + "weekday": DAY_NAMES[parsed_date.weekday()], + "today": int(date == today), + "events": events, + } + ) + +output["entries"] = entries diff --git a/template.yaml b/template.yaml index 18b08ee..f347d7a 100644 --- a/template.yaml +++ b/template.yaml @@ -49,6 +49,7 @@ name: Sun Setting Template state: '{{ as_timestamp(states.sun.sun.attributes.next_setting) | timestamp_custom (''%H:%M'') }}' + - name: "power_other" unique_id: '5579422933393' unit_of_measurement: "W" @@ -79,6 +80,35 @@ attributes: last_reset: '1970-01-01T00:00:00+00:00' + # Compact calendar feed for the e-paper display. The actual event data lives in + # the `entries` attribute, which ESPHome reads as a text sensor. + - trigger: + - trigger: homeassistant + event: start + - trigger: time_pattern + minutes: /15 + action: + - action: calendar.get_events + target: + entity_id: + - calendar.tkrz_kalender + - calendar.privat + data: + duration: + days: 14 + response_variable: calendar_response + - action: python_script.epaper_calendar_data + data: + calendar: "{{ calendar_response }}" + now: "{{ now().date() }}" + response_variable: calendar_converted + sensor: + - name: Epaper Kalenderdaten + unique_id: epaper_calendar_data + state: "{{ calendar_converted.entries | count }}" + attributes: + entries: "{{ calendar_converted.entries }}" + # Shelly 3EM cumulative sensors (incl. PV) - sensor: # Template sensor for values of power import (active_power > 0)