Created
April 29, 2025 19:17
-
-
Save justinledwards/9fcd828126954afaa1c905d21e8cefe5 to your computer and use it in GitHub Desktop.
proper calendar from a single source on esphome
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
esphome: | |
name: epaper | |
friendly_name: epaper | |
esp32: | |
board: esp32-c3-devkitm-1 | |
framework: | |
type: arduino | |
logger: | |
api: | |
encryption: | |
key: "you-better-change-this" | |
ota: | |
- platform: esphome | |
password: "you-better-change-this" | |
# ───────────────────────────────────────────────────────────────────────────── | |
# Globals to track Wi‑Fi and first render | |
# ───────────────────────────────────────────────────────────────────────────── | |
globals: | |
- id: wifi_status | |
type: int | |
initial_value: '0' | |
- id: first_update_done | |
type: bool | |
restore_value: no | |
initial_value: 'false' | |
wifi: | |
ssid: !secret wifi_ssid | |
password: !secret wifi_password | |
on_connect: | |
then: | |
- lambda: 'id(wifi_status) = 1;' | |
on_disconnect: | |
then: | |
- lambda: 'id(wifi_status) = 0;' | |
ap: | |
ssid: "Epaper Fallback Hotspot" | |
password: "you-better-change-this" | |
captive_portal: | |
# ───────────────────────────────────────────────────────────────────────────── | |
# Deep‑sleep settings | |
# ───────────────────────────────────────────────────────────────────────────── | |
deep_sleep: | |
id: deep_sleep_1 | |
run_duration: 1min # awake 60 s to update | |
sleep_duration: 60min # sleep 1 h | |
# ───────────────────────────────────────────────────────────────────────────── | |
# Helper script + intervals | |
# ───────────────────────────────────────────────────────────────────────────── | |
script: | |
- id: update_display | |
then: | |
- component.update: my_display | |
interval: | |
- interval: 10s | |
then: | |
- if: | |
condition: | |
and: | |
- wifi.connected: | |
- lambda: "return !id(first_update_done);" | |
then: | |
- lambda: 'ESP_LOGD("Display", "Updating Display...");' | |
- script.execute: update_display | |
- lambda: 'id(first_update_done) = true;' | |
- interval: 59s | |
then: | |
- logger.log: "Entering deep sleep now..." | |
# ───────────────────────────────────────────────────────────────────────────── | |
# Assets | |
# ───────────────────────────────────────────────────────────────────────────── | |
image: | |
- file: image/wifi.jpg | |
type: BINARY | |
id: esphome_logo | |
resize: 400x240 | |
invert_alpha: true | |
# ───────────────────────────────────────────────────────────────────────────── | |
# Time from Home Assistant | |
# ───────────────────────────────────────────────────────────────────────────── | |
time: | |
- platform: homeassistant | |
id: homeassistant_time | |
# ───────────────────────────────────────────────────────────────────────────── | |
# Sensors | |
# ───────────────────────────────────────────────────────────────────────────── | |
sensor: | |
- platform: homeassistant | |
id: outdoor_temp # °F already | |
entity_id: sensor.living_room_ac_outdoor_temperature | |
text_sensor: | |
# ── Calendar 1 ────────────────────────────────── | |
- platform: homeassistant | |
id: ha_calendar_event_1 | |
entity_id: sensor.event_1_title | |
- platform: homeassistant | |
id: ha_calendar_start_time_1 | |
entity_id: sensor.event_1_start | |
- platform: homeassistant | |
id: ha_calendar_end_time_1 | |
entity_id: sensor.event_1_end | |
# ── Calendar 1 Event 2 ────────────────────────── | |
- platform: homeassistant | |
id: ha_calendar_event_2 | |
entity_id: sensor.event_2_title | |
- platform: homeassistant | |
id: ha_calendar_start_time_2 | |
entity_id: sensor.event_2_start | |
- platform: homeassistant | |
id: ha_calendar_end_time_2 | |
entity_id: sensor.event_2_end | |
# ── Calendar 1 Event 3 ────────────────────────── | |
- platform: homeassistant | |
id: ha_calendar_event_3 | |
entity_id: sensor.event_3_title | |
- platform: homeassistant | |
id: ha_calendar_start_time_3 | |
entity_id: sensor.event_3_start | |
- platform: homeassistant | |
id: ha_calendar_end_time_3 | |
entity_id: sensor.event_3_end | |
# ── Calendar 1 Event 4 ────────────────────────── | |
- platform: homeassistant | |
id: ha_calendar_event_4 | |
entity_id: sensor.event_4_title | |
- platform: homeassistant | |
id: ha_calendar_start_time_4 | |
entity_id: sensor.event_4_start | |
- platform: homeassistant | |
id: ha_calendar_end_time_4 | |
entity_id: sensor.event_4_end | |
# ── Calendar 1 Event 5 ────────────────────────── | |
- platform: homeassistant | |
id: ha_calendar_event_5 | |
entity_id: sensor.event_5_title | |
- platform: homeassistant | |
id: ha_calendar_start_time_5 | |
entity_id: sensor.event_5_start | |
- platform: homeassistant | |
id: ha_calendar_end_time_5 | |
entity_id: sensor.event_5_end | |
# ── Weather entity (state text + other attrs) ── | |
- platform: homeassistant | |
entity_id: weather.forecast_home | |
id: myWeather | |
- platform: homeassistant | |
entity_id: weather.forecast_home | |
id: humi | |
attribute: "humidity" | |
- platform: homeassistant | |
entity_id: weather.forecast_home | |
id: press | |
attribute: "pressure" | |
- platform: homeassistant | |
entity_id: weather.forecast_home | |
id: wind | |
attribute: "wind_speed" | |
# ───────────────────────────────────────────────────────────────────────────── | |
# Fonts | |
# ───────────────────────────────────────────────────────────────────────────── | |
font: | |
- file: "fonts/Montserrat-Black.ttf" | |
id: web_font | |
size: 20 | |
- file: "fonts/Montserrat-Black.ttf" | |
id: data_font | |
size: 30 | |
- file: "fonts/Montserrat-Black.ttf" | |
id: sensor_font | |
size: 22 | |
- file: "gfonts://Inter@700" | |
id: font1 | |
size: 24 | |
# ── Weather glyph families ── | |
- file: 'fonts/materialdesignicons-webfont.ttf' | |
id: font_mdi_large | |
size: 200 | |
glyphs: &mdi-weather-glyphs | |
- "\U000F050F" # Thermometer | |
- "\U000F058E" # Humidity | |
- "\U000F059D" # Wind speed | |
- "\U000F0D60" # Atmospheric pressure | |
- "\U000F0590" # Cloudy | |
- "\U000F0596" # Rainy | |
- "\U000F0598" # Snowy | |
- "\U000F0599" # Sunny | |
- file: 'fonts/materialdesignicons-webfont.ttf' | |
id: font_weather | |
size: 200 | |
glyphs: *mdi-weather-glyphs | |
- file: 'fonts/materialdesignicons-webfont.ttf' | |
id: img_font_sensor | |
size: 70 | |
glyphs: *mdi-weather-glyphs | |
# ── New: smaller weather icon ── | |
- file: 'fonts/materialdesignicons-webfont.ttf' | |
id: font_weather_small | |
size: 160 | |
glyphs: *mdi-weather-glyphs | |
# ── New: tiny status text ── | |
- file: "gfonts://Inter@500" | |
id: tiny_font | |
size: 18 | |
# ───────────────────────────────────────────────────────────────────────────── | |
# SPI & Display | |
# ───────────────────────────────────────────────────────────────────────────── | |
spi: | |
clk_pin: GPIO8 | |
mosi_pin: GPIO10 | |
display: | |
- platform: waveshare_epaper | |
id: my_display | |
cs_pin: GPIO3 | |
dc_pin: GPIO5 | |
busy_pin: GPIO4 | |
reset_pin: GPIO2 | |
model: 7.50inv2 | |
update_interval: 50s | |
lambda: |- | |
if (id(wifi_status) == 0) { | |
it.image(180, 0, id(esphome_logo)); | |
it.print(230, 300, id(data_font), "WI-FI CONNECTING"); | |
} else { | |
// ── Weather glyph ─────────────────────────────────────────────── | |
std::string weather_string = id(myWeather).state.c_str(); | |
if (weather_string == "rainy" || weather_string == "lightning" || weather_string == "pouring") { | |
it.printf(120, 85, id(font_weather_small), TextAlign::CENTER, "\U000F0596"); | |
} else if (weather_string == "snowy") { | |
it.printf(120, 85, id(font_weather_small), TextAlign::CENTER, "\U000F0598"); | |
} else if (weather_string == "sunny" || weather_string == "windy") { | |
it.printf(120, 85, id(font_weather_small), TextAlign::CENTER, "\U000F0599"); | |
} else { | |
it.printf(120, 85, id(font_weather_small), TextAlign::CENTER, "\U000F0590"); | |
} | |
// ── Date / time ──────────────────────────────────────────────── | |
auto time_now = id(homeassistant_time).now(); | |
const char* months[] = { | |
"January", "February", "March", "April", "May", "June", | |
"July", "August", "September", "October", "November", "December"}; | |
const char* month_str = months[time_now.month - 1]; | |
int day = time_now.day_of_month; | |
char update_buf[10]; | |
time_t now_ts = time_now.timestamp; | |
struct tm update_tm; | |
localtime_r(&now_ts, &update_tm); | |
strftime(update_buf, sizeof(update_buf), "%I:%M%p", &update_tm); | |
it.printf(245, 50, id(tiny_font), "Last Update %s", update_buf); | |
// Put the names in the order ESPHome expects (Sunday = 1 … Saturday = 7) | |
const char* days[] = {"Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"}; | |
const char* day_of_week = days[time_now.day_of_week - 1]; // subtract 1 => 0-6 | |
it.printf(245, 70, id(data_font), "%s", day_of_week); | |
it.printf(250, 110, id(data_font), "%s %d", month_str, day); | |
// ── Calendar pane ──────────────────────────────────────────────── | |
it.filled_rectangle(433, 30, 5, 430); // vertical divider | |
it.printf(540, 40, id(data_font), "Calendar"); | |
struct Event { | |
std::string title; | |
std::string start_time; | |
std::string end_time; | |
time_t start_timestamp; | |
}; | |
auto parse_time = [](const std::string &time_str) -> time_t { | |
if (time_str.empty()) return 0; | |
struct tm timeinfo = {}; | |
// Try parsing with timezone first | |
if (strptime(time_str.c_str(), "%Y-%m-%dT%H:%M:%S%z", &timeinfo) == nullptr) { | |
// If that fails, try without timezone | |
if (strptime(time_str.c_str(), "%Y-%m-%dT%H:%M:%S", &timeinfo) == nullptr) { | |
ESP_LOGD("Calendar", "Failed to parse time: %s", time_str.c_str()); | |
return 0; | |
} | |
} | |
time_t t = mktime(&timeinfo); | |
ESP_LOGD("Calendar", "Parsed time: %s -> %ld", time_str.c_str(), t); | |
return t; | |
}; | |
std::vector<Event> events = { | |
{id(ha_calendar_event_1).state, id(ha_calendar_start_time_1).state, id(ha_calendar_end_time_1).state, parse_time(id(ha_calendar_start_time_1).state)}, | |
{id(ha_calendar_event_2).state, id(ha_calendar_start_time_2).state, id(ha_calendar_end_time_2).state, parse_time(id(ha_calendar_start_time_2).state)}, | |
{id(ha_calendar_event_3).state, id(ha_calendar_start_time_3).state, id(ha_calendar_end_time_3).state, parse_time(id(ha_calendar_start_time_3).state)}, | |
{id(ha_calendar_event_4).state, id(ha_calendar_start_time_4).state, id(ha_calendar_end_time_4).state, parse_time(id(ha_calendar_start_time_4).state)}, | |
{id(ha_calendar_event_5).state, id(ha_calendar_start_time_5).state, id(ha_calendar_end_time_5).state, parse_time(id(ha_calendar_start_time_5).state)} | |
}; | |
ESP_LOGD("Calendar", "Raw events count: %d", events.size()); | |
for (const auto &e : events) { | |
ESP_LOGD("Calendar", "Event: title='%s' start='%s' end='%s' timestamp=%ld", | |
e.title.c_str(), e.start_time.c_str(), e.end_time.c_str(), e.start_timestamp); | |
} | |
events.erase(std::remove_if(events.begin(), events.end(), [](const Event &e){ | |
bool remove = e.start_timestamp == 0 || e.title.empty(); | |
if (remove) { | |
ESP_LOGD("Calendar", "Removing event: title='%s' (empty=%d, timestamp=0=%d)", | |
e.title.c_str(), e.title.empty(), e.start_timestamp == 0); | |
} | |
return remove; | |
}), events.end()); | |
ESP_LOGD("Calendar", "Filtered events count: %d", events.size()); | |
std::sort(events.begin(), events.end(), [](const Event &a, const Event &b){ return a.start_timestamp < b.start_timestamp; }); | |
auto format_time = [](const std::string &str) -> std::string { | |
if (str.empty()) return ""; | |
struct tm ti = {}; | |
if (strptime(str.c_str(), "%Y-%m-%dT%H:%M:%S%z", &ti) == nullptr) { | |
if (strptime(str.c_str(), "%Y-%m-%dT%H:%M:%S", &ti) == nullptr) { | |
ESP_LOGD("Calendar", "Failed to format time: %s", str.c_str()); | |
return ""; | |
} | |
} | |
char buf[10]; | |
strftime(buf, sizeof(buf), "%I:%M%p", &ti); | |
return std::string(buf); | |
}; | |
auto format_date = [](const std::string &str) -> std::string { | |
if (str.empty()) return ""; | |
struct tm ti = {}; | |
if (strptime(str.c_str(), "%Y-%m-%dT%H:%M:%S%z", &ti) == nullptr) { | |
if (strptime(str.c_str(), "%Y-%m-%dT%H:%M:%S", &ti) == nullptr) { | |
ESP_LOGD("Calendar", "Failed to format date: %s", str.c_str()); | |
return ""; | |
} | |
} | |
char buf[6]; | |
strftime(buf, sizeof(buf), "%m-%d", &ti); | |
return std::string(buf); | |
}; | |
int ex = 460, ey = 80; | |
for (const auto &evt : events) { | |
if (ey >= 420) break; | |
std::string d = format_date(evt.start_time); | |
std::string t1 = format_time(evt.start_time); | |
std::string t2 = format_time(evt.end_time); | |
std::string range = d + " " + t1 + " - " + t2; | |
ESP_LOGD("Calendar", "Drawing event: range='%s' title='%s'", range.c_str(), evt.title.c_str()); | |
it.printf(ex, ey, id(sensor_font), "%s", range.c_str()); | |
ey += 30; | |
it.printf(ex, ey, id(sensor_font), "%s", evt.title.c_str()); | |
ey += 40; | |
} | |
// ── Sensor boxes ─────────────────────────────────────────────── | |
int x = 20, y = 180, w = 180, h = 120, r = 10, thickness = 4; | |
// Temperature box | |
it.filled_rectangle(x + r, y, w - 2 * r, thickness); // Top | |
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); // Bottom | |
it.filled_rectangle(x, y + r, thickness, h - 2 * r); // Left | |
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); // Right | |
it.filled_circle(x + r, y + r, r); | |
it.filled_circle(x + w - r, y + r, r); | |
it.filled_circle(x + r, y + h - r, r); | |
it.filled_circle(x + w - r, y + h - r, r); | |
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF); | |
it.printf(x + 10, y + 10, id(sensor_font), "Temperature"); | |
it.printf(x + 45, y + 75, id(img_font_sensor), TextAlign::CENTER, "\U000F050F"); | |
it.printf(x + 75, y + 65, id(data_font), "%.1f°F", id(outdoor_temp).state); | |
// Humidity box | |
x = 220; y = 180; | |
it.filled_rectangle(x + r, y, w - 2 * r, thickness); | |
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); | |
it.filled_rectangle(x, y + r, thickness, h - 2 * r); | |
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); | |
it.filled_circle(x + r, y + r, r); | |
it.filled_circle(x + w - r, y + r, r); | |
it.filled_circle(x + r, y + h - r, r); | |
it.filled_circle(x + w - r, y + h - r, r); | |
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF); | |
it.printf(x + 10, y + 10, id(sensor_font), "Humidity"); | |
it.printf(x + 45, y + 75, id(img_font_sensor), TextAlign::CENTER, "\U000F058E"); | |
it.printf(x + 75, y + 65, id(data_font), "%s%%", id(humi).state.c_str()); | |
// Pressure box | |
x = 20; y = 320; | |
it.filled_rectangle(x + r, y, w - 2 * r, thickness); | |
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); | |
it.filled_rectangle(x, y + r, thickness, h - 2 * r); | |
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); | |
it.filled_circle(x + r, y + r, r); | |
it.filled_circle(x + w - r, y + r, r); | |
it.filled_circle(x + r, y + h - r, r); | |
it.filled_circle(x + w - r, y + h - r, r); | |
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF); | |
it.printf(x + 10, y + 10, id(sensor_font), "Air Pressure"); | |
it.printf(x + 45, y + 75, id(img_font_sensor), TextAlign::CENTER, "\U000F0D60"); | |
float pressure = atof(id(press).state.c_str()); | |
int rounded_pressure = (pressure - floor(pressure) > 0.5) ? ceil(pressure) : floor(pressure); | |
it.printf(x + 85, y + 50, id(data_font), "%d", rounded_pressure); | |
it.printf(x + 85, y + 78, id(sensor_font), "inHg"); | |
// Wind box | |
x = 220; y = 320; | |
it.filled_rectangle(x + r, y, w - 2 * r, thickness); | |
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); | |
it.filled_rectangle(x, y + r, thickness, h - 2 * r); | |
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); | |
it.filled_circle(x + r, y + r, r); | |
it.filled_circle(x + w - r, y + r, r); | |
it.filled_circle(x + r, y + h - r, r); | |
it.filled_circle(x + w - r, y + h - r, r); | |
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF); | |
it.printf(x + 10, y + 10, id(sensor_font), "Wind Speed"); | |
it.printf(x + 45, y + 75, id(img_font_sensor), TextAlign::CENTER, "\U000F059D"); | |
float wind_speed = atof(id(wind).state.c_str()); | |
int rounded_wind = (wind_speed - floor(wind_speed) > 0.5) ? ceil(wind_speed) : floor(wind_speed); | |
it.printf(x + 85, y + 50, id(data_font), "%d", rounded_wind); | |
it.printf(x + 85, y + 78, id(sensor_font), "mph"); | |
} | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# configuration.yaml (or an included template file) | |
template: | |
- trigger: | |
# run every 15 min *and* once at startup | |
- platform: time_pattern | |
minutes: '/15' | |
- platform: homeassistant | |
event: start | |
# 1️⃣ Pull events from the Family calendar | |
action: | |
- service: calendar.get_events | |
target: | |
entity_id: calendar.family | |
data: | |
duration: # cleaner than building start/end strings | |
days: 30 | |
response_variable: raw_events | |
# 2️⃣ Promote the list of events to a Jinja variable so every | |
# sensor only has to write `events` instead of the long path. | |
- variables: | |
events: "{{ raw_events['calendar.family'].events }}" | |
# 3️⃣ Sensors ---------------------------------------------------- | |
sensor: | |
# ----- Event 1 ----- | |
- name: "Event 1 Title" | |
unique_id: event_1_title | |
availability: "{{ events is defined }}" | |
state: "{{ events[0].summary if events|count > 0 else 'No upcoming events' }}" | |
- name: "Event 1 Start" | |
unique_id: event_1_start | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 0 %} | |
{{ events[0].start | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
- name: "Event 1 End" | |
unique_id: event_1_end | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 0 %} | |
{{ events[0].end | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
# ----- Event 2 ----- | |
- name: "Event 2 Title" | |
unique_id: event_2_title | |
availability: "{{ events is defined }}" | |
state: "{{ events[1].summary if events|count > 1 else '' }}" | |
- name: "Event 2 Start" | |
unique_id: event_2_start | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 1 %} | |
{{ events[1].start | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
- name: "Event 2 End" | |
unique_id: event_2_end | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 1 %} | |
{{ events[1].end | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
# ----- Event 3 ----- | |
- name: "Event 3 Title" | |
unique_id: event_3_title | |
availability: "{{ events is defined }}" | |
state: "{{ events[2].summary if events|count > 2 else '' }}" | |
- name: "Event 3 Start" | |
unique_id: event_3_start | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 2 %} | |
{{ events[2].start | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
- name: "Event 3 End" | |
unique_id: event_3_end | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 2 %} | |
{{ events[2].end | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
# ----- Event 4 ----- | |
- name: "Event 4 Title" | |
unique_id: event_4_title | |
availability: "{{ events is defined }}" | |
state: "{{ events[3].summary if events|count > 3 else '' }}" | |
- name: "Event 4 Start" | |
unique_id: event_4_start | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 3 %} | |
{{ events[3].start | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
- name: "Event 4 End" | |
unique_id: event_4_end | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 3 %} | |
{{ events[3].end | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
# ----- Event 5 ----- | |
- name: "Event 5 Title" | |
unique_id: event_5_title | |
availability: "{{ events is defined }}" | |
state: "{{ events[4].summary if events|count > 4 else '' }}" | |
- name: "Event 5 Start" | |
unique_id: event_5_start | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 4 %} | |
{{ events[4].start | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} | |
- name: "Event 5 End" | |
unique_id: event_5_end | |
availability: "{{ events is defined }}" | |
state: >- | |
{% if events|count > 4 %} | |
{{ events[4].end | as_datetime | as_timestamp | timestamp_local }} | |
{% else %} {% endif %} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment