Skip to content

Instantly share code, notes, and snippets.

@justinledwards
Created April 29, 2025 19:17
Show Gist options
  • Save justinledwards/9fcd828126954afaa1c905d21e8cefe5 to your computer and use it in GitHub Desktop.
Save justinledwards/9fcd828126954afaa1c905d21e8cefe5 to your computer and use it in GitHub Desktop.
proper calendar from a single source on esphome
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");
}
# 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