# E-Ink Frame/Dashboard # Michi von Ah - 2025 # GitHub: https://github.com/michivonah/esphome-eink-dashboard # Inspired by (based on) https://github.com/Madelena/esphome-weatherman-dashboard/blob/main/weatherman.yaml esphome: name: e-ink-frame friendly_name: E-Ink_Frame on_boot: priority: 200.0 then: - component.update: eink_display - wait_until: condition: lambda: 'return id(data_updated) == true;' # Wait a bit longer so all the items are received - delay: 5s - logger.log: "Initial sensor data received: Refreshing display..." - lambda: 'id(initial_data_received) = true;' - script.execute: update_screen esp32: board: esp32dev framework: type: arduino # Enable logging logger: level: DEBUG # Enable Home Assistant API api: encryption: key: !secret api_key_e-ink-frame ota: - platform: esphome password: !secret ota_key_e-ink-frame wifi: ssid: !secret wifi_ssid password: !secret wifi_password fast_connect: true # Include custom fonts font: - file: 'fonts/Roboto-Medium.ttf' id: font_small_book size: 18 - file: 'fonts/Roboto-Bold.ttf' id: font_large_bold size: 82 glyphs: [' ', '-', '°', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', ',', '.'] - file: 'fonts/Roboto-Bold.ttf' id: font_title size: 54 glyphs: ['W', 'E', 'A', 'T', 'H', 'R', 'L', 'I', 'N', 'M', 'U', 'S', 'C', ' '] - file: 'fonts/Roboto-Bold.ttf' id: font_medium_bold size: 30 glyphs: ['&', '@', '!', ',', '.', '?', '"', '%', '(', ')', '+', '-', '_', ':', ';', '°', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ' ', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z','å', 'Ä', 'ä', 'Ö', 'ö', 'Ü', 'ü', '/', '#'] - file: 'fonts/Roboto-Bold.ttf' id: font_small_bold size: 18 glyphs: ['&', '@', '!', ',', '.', '?', '"', '%', '(', ')', '+', '-', '_', ':', ';', '°', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ' ', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z','å', 'Ä', 'ä', 'Ö', 'ö', 'Ü', 'ü', '/', '#'] # Include Material Design Icons font # Thanks to https://community.home-assistant.io/t/display-materialdesign-icons-on-esphome-attached-to-screen/199790/16 - file: 'fonts/materialdesignicons-webfont.ttf' id: font_mdi_large size: 78 glyphs: &mdi-icons - "\U000F0590" # mdi-weather-cloudy - "\U000F0F2F" # mdi-weather-cloudy-alert - "\U000F0E6E" # mdi-weather-cloudy-arrow-right - "\U000F0591" # mdi-weather-fog - "\U000F0592" # mdi-weather-hail - "\U000F0F30" # mdi-weather-hazy - "\U000F0898" # mdi-weather-hurricane - "\U000F0593" # mdi-weather-lightning - "\U000F067E" # mdi-weather-lightning-rainy - "\U000F0594" # mdi-weather-night - "\U000F0F31" # mdi-weather-night-partly-cloudy - "\U000F0595" # mdi-weather-partly-cloudy - "\U000F0F32" # mdi-weather-partly-lightning - "\U000F0F33" # mdi-weather-partly-rainy - "\U000F0F34" # mdi-weather-partly-snowy - "\U000F0F35" # mdi-weather-partly-snowy-rainy - "\U000F0596" # mdi-weather-pouring - "\U000F0597" # mdi-weather-rainy - "\U000F0598" # mdi-weather-snowy - "\U000F0F36" # mdi-weather-snowy-heavy - "\U000F067F" # mdi-weather-snowy-rainy - "\U000F0599" # mdi-weather-sunny - "\U000F0F37" # mdi-weather-sunny-alert - "\U000F14E4" # mdi-weather-sunny-off - "\U000F059A" # mdi-weather-sunset - "\U000F059B" # mdi-weather-sunset-down - "\U000F059C" # mdi-weather-sunset-up - "\U000F0F38" # mdi-weather-tornado - "\U000F059D" # mdi-weather-windy - "\U000F059E" # mdi-weather-windy-variant - "\U000F029A" # mdi:gauge - "\U000F09A1" # mdi:shower-head - "\U000F040A" # mdi:play - "\U000F03E4" # mdi:pause - "\U000F0F54" # mdi:home-thermometer - "\U000F1A35" # mdi:cloud-percent - file: 'fonts/materialdesignicons-webfont.ttf' id: font_mdi_medium size: 42 glyphs: *mdi-icons # Button definitions button: - platform: shutdown name: "Shutdown" - platform: restart name: "Restart" - platform: template name: "Refresh Screen" entity_category: config on_press: - script.execute: update_screen globals: - id: data_updated type: bool restore_value: no initial_value: 'false' - id: initial_data_received type: bool restore_value: no initial_value: 'false' - id: recorded_display_refresh type: int restore_value: yes initial_value: '0' # Script for updating screen - Refresh display and publish refresh count and time. (Thanks @paviro!) script: - id: update_screen then: - lambda: 'id(data_updated) = false;' - component.update: eink_display - lambda: 'id(recorded_display_refresh) += 1;' - lambda: 'id(display_last_update).publish_state(id(homeassistant_time).now().timestamp);' time: - platform: homeassistant id: homeassistant_time timezone: Europe/Zurich on_time: - seconds: 0 minutes: /1 then: - if: condition: lambda: 'return id(data_updated) == true;' then: - if: condition: binary_sensor.is_on: presence_detected then: - logger.log: "Sensor data updated and activity in home detected: Refreshing display..." - script.execute: update_screen else: - logger.log: "Sensor data updated but no activity in home - skipping display refresh." else: - logger.log: "No sensors updated - skipping display refresh." # Check if motion is detected in the living room. binary_sensor: - platform: homeassistant entity_id: id: presence_detected sensor: # Create sensors for monitoring Weatherman remotely. - platform: template name: "Display Last Update" device_class: timestamp entity_category: "diagnostic" id: display_last_update - platform: template name: "Recorded Display Refresh" accuracy_decimals: 0 unit_of_measurement: "Refreshes" state_class: "total_increasing" entity_category: "diagnostic" lambda: 'return id(recorded_display_refresh);' - platform: wifi_signal name: "WiFi Signal Strength" id: wifisignal unit_of_measurement: "dBm" entity_category: "diagnostic" update_interval: 60s - platform: homeassistant entity_id: weather. attribute: temperature id: weather_temperature on_value: then: - lambda: 'id(data_updated) = true;' - platform: homeassistant entity_id: id: weather_rain on_value: then: - lambda: 'id(data_updated) = true;' - platform: homeassistant entity_id: id: indoor_iaq on_value: then: - lambda: 'id(data_updated) = true;' - platform: homeassistant entity_id: id: indoor_temperature on_value: then: - lambda: 'id(data_updated) = true;' text_sensor: - platform: homeassistant entity_id: weather. id: weather_state on_value: then: - lambda: 'id(data_updated) = true;' - platform: homeassistant entity_id: id: media_player_sonos_state - platform: homeassistant entity_id: attribute: media_title id: media_player_sonos_song on_value: then: - lambda: 'id(data_updated) = true;' - platform: homeassistant entity_id: attribute: media_artist id: media_player_sonos_artist - platform: homeassistant entity_id: id: media_player_spotify_state - platform: homeassistant entity_id: attribute: media_title id: media_player_spotify_song on_value: then: - lambda: 'id(data_updated) = true;' - platform: homeassistant entity_id: attribute: media_artist id: media_player_spotify_artist - platform: homeassistant entity_id: id: shower_time # Define colors # This design is white on black so this is necessary. color: - id: color_bg red: 0% green: 0% blue: 0% white: 100% - id: color_text red: 0% green: 0% blue: 0% white: 0% # Pins for Waveshare ePaper ESP Board spi: clk_pin: GPIO13 mosi_pin: GPIO14 # Now render everything on the ePaper screen. display: - platform: waveshare_epaper id: eink_display cs_pin: GPIO15 dc_pin: GPIO27 busy_pin: number: GPIO25 inverted: True reset_pin: GPIO26 reset_duration: 2ms model: 7.50inV2p update_interval: never full_update_every: 30 rotation: 270° lambda: |- // Map weather states to MDI characters. std::map weather_icon_map { {"cloudy", "\U000F0590"}, {"cloudy-alert", "\U000F0F2F"}, {"cloudy-arrow-right", "\U000F0E6E"}, {"fog", "\U000F0591"}, {"hail", "\U000F0592"}, {"hazy", "\U000F0F30"}, {"hurricane", "\U000F0898"}, {"lightning", "\U000F0593"}, {"lightning-rainy", "\U000F067E"}, {"night", "\U000F0594"}, {"night-partly-cloudy", "\U000F0F31"}, {"partlycloudy", "\U000F0595"}, {"partly-lightning", "\U000F0F32"}, {"partly-rainy", "\U000F0F33"}, {"partly-snowy", "\U000F0F34"}, {"partly-snowy-rainy", "\U000F0F35"}, {"pouring", "\U000F0596"}, {"rainy", "\U000F0597"}, {"snowy", "\U000F0598"}, {"snowy-heavy", "\U000F0F36"}, {"snowy-rainy", "\U000F067F"}, {"sunny", "\U000F0599"}, {"sunny-alert", "\U000F0F37"}, {"sunny-off", "\U000F14E4"}, {"sunset", "\U000F059A"}, {"sunset-down", "\U000F059B"}, {"sunset-up", "\U000F059C"}, {"tornado", "\U000F0F38"}, {"windy", "\U000F059D"}, {"windy-variant", "\U000F059E"}, }; // Fill background. it.fill(color_bg); // Show loading screen before data is received. if (id(initial_data_received) == false) { it.printf(240, 390, id(font_small_bold), color_text, TextAlign::TOP_CENTER, "WAITING FOR DATA..."); } else { // GENERAL PARAMS uint base_x = 240; uint base_y = 84; // Weather section it.printf(base_x, base_y, id(font_title), color_text, TextAlign::TOP_CENTER, "WEATHER"); it.printf(114, 178, id(font_mdi_large), color_text, TextAlign::TOP_CENTER, "%s", weather_icon_map[id(weather_state).state.c_str()].c_str()); it.printf(168, 168, id(font_large_bold), color_text, "%2.1f°C", id(weather_temperature).state); // Music section uint music_base_x = base_x; uint music_base_y = 296; uint music_content_offset_y = 80; uint music_linebreak_y = 50; it.printf(music_base_x, music_base_y, id(font_title), color_text, TextAlign::TOP_CENTER, "MUSIC"); String sonos_state = id(media_player_sonos_state).state.c_str(); String spotify_state = id(media_player_spotify_state).state.c_str(); String song = ""; String artist = ""; bool isPlaying = false; if (sonos_state == "playing") { isPlaying = true; song = id(media_player_sonos_song).state.c_str(); artist = id(media_player_sonos_artist).state.c_str(); } else if (spotify_state == "playing"){ isPlaying = true; song = id(media_player_spotify_song).state.c_str(); artist = id(media_player_spotify_artist).state.c_str(); } else{ isPlaying = false; } // show current song if music is playing if (isPlaying){ if (song.length() > 35) { it.printf(music_base_x, music_base_y + music_content_offset_y, id(font_small_bold), color_text, TextAlign::TOP_CENTER, "%s", song.c_str()); } else { it.printf(music_base_x, music_base_y + music_content_offset_y, id(font_medium_bold), color_text, TextAlign::TOP_CENTER, "%s", song.c_str()); } if (artist.length() > 35) { it.printf(music_base_x, music_base_y + music_content_offset_y + music_linebreak_y, id(font_small_bold), color_text, TextAlign::TOP_CENTER, "by %s", artist.c_str()); } else { it.printf(music_base_x, music_base_y + music_content_offset_y + music_linebreak_y, id(font_medium_bold), color_text, TextAlign::TOP_CENTER, "by %s", artist.c_str()); } } else{ it.printf(music_base_x, music_base_y + music_content_offset_y, id(font_medium_bold), color_text, TextAlign::TOP_CENTER, "No music playing"); } // Multiple infos with icon // General settings uint multi_info_base_x = 78; uint text_offset_x = 48; uint tab_offset_x = 172; uint multi_info_base_y = music_base_y + 220; uint text_offset_y = 4; uint linebreak_offset_y = 60; // IAQ value it.printf(multi_info_base_x, multi_info_base_y, id(font_mdi_medium), color_text, "\U000F029A"); it.printf(multi_info_base_x + text_offset_x, multi_info_base_y + text_offset_y, id(font_medium_bold), color_text, "%.0f IAQ", id(indoor_iaq).state); // Indoor temperature it.printf(multi_info_base_x, multi_info_base_y + linebreak_offset_y, id(font_mdi_medium), color_text, "\U000F0F54"); it.printf(multi_info_base_x + text_offset_x, multi_info_base_y + linebreak_offset_y + text_offset_y, id(font_medium_bold), color_text, "%.1f°C", id(indoor_temperature).state); // Last shower timestamp it.printf(multi_info_base_x + tab_offset_x, multi_info_base_y, id(font_mdi_medium), color_text, "\U000F09A1"); String shower_datetime_str = id(shower_time).state.c_str(); int year = atoi(shower_datetime_str.c_str()); int month = atoi(shower_datetime_str.c_str() + 5); int day = atoi(shower_datetime_str.c_str() + 8); int hour = atoi(shower_datetime_str.c_str() + 11); int minute = atoi(shower_datetime_str.c_str() + 14); // calculate day (calculation by gpt-4.1-mini) int y = year; int m = month; int d = day; if (m < 3) { m += 12; y -= 1; } int K = y % 100; int J = y / 100; int f = d + int((13*(m + 1))/5) + K + int(K/4) + int(J/4) + 5*J; int weekday = ((f + 5) % 7) + 1; const char* weekdays_de[] = { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" }; const char* wday_name = (weekday >=1 && weekday <=7) ? weekdays_de[weekday - 1] : "?"; it.printf(multi_info_base_x + tab_offset_x + text_offset_x, multi_info_base_y + text_offset_y, id(font_medium_bold), color_text, "%s, %02d:%02d", wday_name, hour, minute); // Rain amount (weather) it.printf(multi_info_base_x + tab_offset_x, multi_info_base_y + linebreak_offset_y, id(font_mdi_medium), color_text, "\U000F1A35"); it.printf(multi_info_base_x + tab_offset_x + text_offset_x, multi_info_base_y + linebreak_offset_y + text_offset_y, id(font_medium_bold), color_text, "%.1fmm", id(weather_rain).state); // Refresh Timestamp // Code by EnsconcE from https://community.home-assistant.io/t/esphome-show-time/348903 char str[17]; time_t currTime = id(homeassistant_time).now().timestamp; strftime(str, sizeof(str), "%H:%M", localtime(&currTime)); it.printf(240, 710, id(font_small_book), color_text, TextAlign::TOP_CENTER, "REFRESHED AT %s", str); }