A battery-powered e-ink dashboard for Home Assistant, built with ESPHome on a LOLIN S3 Pro (ESP32-S3) and a Waveshare 7.5" e-Paper display. It wakes up every 20 minutes, pulls data from Home Assistant, renders the screen, and goes back to deep sleep — lasting about a month+ on a 2500 mAh battery.
Inspired by Madelena's Weatherman Dashboard.
Note: The on-screen UI language is Polish. All code comments are in English.
The dashboard is built around a few fixed layout zones — you keep what's useful and remove what isn't. All data sources are optional except weather.
- Current weather — large temperature + MDI condition icon, indoor temperature & humidity
- 4-day forecast — per-day condition icons and max temperatures
- Sensor data — any HA sensors: temperature, humidity, pressure, air quality, UV index, or anything else numeric
- Calendar — events from one or more HA calendars, with optional keyword-based logic (work shifts, vacations, custom labels)
- Any numeric HA data — exchange rates, fuel prices, EV status, task counters, or any sensor you have
- Deep sleep — configurable wake/sleep cycle; ~1 month on 2500 mAh LiPo with default settings (90 s / 20 min)
- Battery & WiFi status in the footer, with alert below 15%
- Custom PCB available (optional) — Gerber files included in
assets/
┌─────────────┐ WiFi ┌────────────────┐
│ ESP32-S3 │ ──────────────> │ Home Assistant │
│ (LOLIN S3) │ <────────────── │ (API) │
└──────┬──────┘ sensors + └────────────────┘
│ calendar +
│ weather data
▼
┌─────────────┐
│ Waveshare │
│ 7.5" e-ink │
└─────────────┘
Boot cycle:
- ESP32 wakes from deep sleep
- Powers on the e-paper display (GPIO15)
- Connects to WiFi and Home Assistant API (timeout: 20 s)
- Waits for sensor data — weather, calendar, battery (timeout: 30 s)
- Renders the full display layout in one pass
- Waits 5 s for the e-ink to finish physical rendering
- Enters deep sleep for 20 minutes
- Repeat
The display is 800×480 px, rotated 90° (portrait orientation: 480 wide × 800 tall). It is divided into five horizontal zones stacked top to bottom:
Zone 1 — Current weather (top, ~225 px)
Large condition icon (MDI, 96 pt) on the left, current outdoor temperature (108 pt bold) in the centre, indoor temperature and humidity on the right.
Zone 2 — 4-day forecast (~95 px)
Four columns, each showing a weekday abbreviation, a weather icon (36 pt MDI), and the forecast maximum temperature.
Zone 3 — Left column: date & events (remaining height, left 57%)
Current date (day name + numeric date), then configurable data rows (exchange rate, task count, precipitation, or anything numeric), followed by a calendar section with today's and tomorrow's events.
Zone 4 — Right column: sensor data (remaining height, right 43%)
Sensor readings with MDI icons (temperature, humidity, pressure, UV index, air quality, fuel price), then a secondary section for any additional data you want — EV status, energy usage, custom sensors, etc.
Zone 5 — Footer (bottom ~20 px)
WiFi signal strength icon + dBm value, battery icon + percent, last update timestamp — fixed to the bottom edge.
| Component | Notes |
|---|---|
| LOLIN S3 Pro (ESP32-S3) | Development board — see Why LOLIN S3 Pro? below |
| Waveshare 7.5" e-Paper V2 | 800×480 px, monochrome (black/white), SPI interface |
| LiPo battery (3.7 V) or USB-C cable | 2500 mAh recommended for ~1 month battery life |
| Component | Notes |
|---|---|
| Custom PCB | Simplifies wiring, adds basic reverse polarity protection via AO3401 MOSFET — Gerber files included. Without the PCB there is no reverse polarity protection — see Electrical & Hardware Safety |
| IKEA RÖDALM frame (13×18 cm) | For a clean wall-mounted look |
| Outdoor/indoor sensors (e.g. BME280 + PMS5003) | For environmental data — any HA-compatible sensor works |
The LOLIN S3 Pro was chosen specifically for battery-powered e-ink projects:
- Built-in LiPo battery management — charging circuit with JST connector, no external TP4056 module needed
- Battery voltage via ADC (GPIO3) — the board has a built-in voltage divider; just read the pin and multiply by 2
- Excellent deep sleep performance — ESP32-S3 draws very low current in deep sleep
- ~1 month on 2500 mAh — with current settings (20 min sleep / 90 s wake cycle), real-world battery life is approximately one month
- USB-C — modern connector for both charging and programming
- Compact form factor — fits well inside picture frames
Connect the Waveshare e-Paper to the LOLIN S3 Pro:
| ESP32 GPIO | e-Paper Pin | Function |
|---|---|---|
| GPIO18 | CLK | SPI Clock |
| GPIO11 | DIN | SPI MOSI (data) |
| GPIO5 | CS | Chip Select |
| GPIO16 | DC | Data/Command |
| GPIO4 | BUSY | Busy signal (active LOW — inverted in config) |
| GPIO17 | RST | Reset (10 ms pulse) |
| GPIO15 | — | Display power control (turn on/off via MOSFET or direct) |
Optional pins:
| ESP32 GPIO | Function |
|---|---|
| GPIO3 | Battery ADC — reads voltage through built-in divider (×2) |
| GPIO2 | Deep sleep ON/OFF slide switch (INPUT_PULLDOWN, inverted) |
| GPIO1 | Wakeup pin (keeps awake when active) |
The project includes a custom PCB designed in EasyEDA (credit: WR Electro). It integrates:
- AO3401 P-MOSFET (Q1) — routes battery power to the LOLIN board; its body diode provides basic reverse polarity protection (see Electrical & Hardware Safety)
- SS-12D10G5 slide switch (U3) — hardware deep sleep enable/disable
- 10 kΩ resistor (R1) — MOSFET gate pull control
- Screw terminals (H1) — external battery connection
- HX 2.0mm 9P connectors (H3, U4) — e-Paper ribbon cable breakout
- JST battery connector (H2) — connects to LOLIN's built-in charger
Physical Assembly:
Gerber files for PCB manufacturing: assets/Gerber_PCB_epaper_dashboard.zip
You can order the PCB from JLCPCB, PCBWay, or any other manufacturer. Upload the Gerber ZIP and use default settings (2 layers, 1.6 mm thickness).
| Requirement | Details |
|---|---|
| Home Assistant | Any installation method (HAOS, Docker, Core, Supervised) |
| ESPHome | As HA add-on or standalone CLI |
| Weather integration | e.g. Met.no — entity: weather.forecast_home (or similar) |
| Calendar integration(s) | Optional — up to 3 calendars for events/shifts/holidays |
These are used in the default config but can be removed or replaced:
| Integration | Substitution variable | Purpose |
|---|---|---|
| Outdoor sensor | ha_outdoor_temp/humidity/pressure/air_entity |
Temperature, humidity, pressure, PM2.5 |
| Indoor sensor | ha_indoor_temp_entity, ha_indoor_humidity_entity |
Indoor temp & humidity |
| EV / car integration | ha_ev_battery_entity, ha_ev_range_entity, ha_fuel_range_entity |
EV battery, electric range, fuel range |
| Fuel price sensor | ha_fuel_price_entity |
Local fuel price (€) |
| Currency exchange | ha_currency_entity |
Exchange rate |
| Task tracker | ha_tasks_entity |
Open household tasks |
For users already familiar with Home Assistant and ESPHome.
-
Clone this repository:
git clone https://github.com/pavlojs/esphome-epaper-dashboard.git
-
Copy fonts to your ESPHome config directory:
cp -r fonts/ /config/esphome/fonts/ # or ~/.config/esphome/fonts/ for CLI users -
Create
secrets.yaml(if not already present) in your ESPHome config directory:wifi_ssid: "YourWiFiSSID" wifi_password: "YourWiFiPassword"
-
Edit
dashboard.yaml— fill in thesubstitutions:block at the top of the file with your own entity IDs and labels (see Customization) -
Flash the LOLIN S3 Pro:
- Via ESPHome Dashboard: upload
dashboard.yaml, click Install - Via CLI:
esphome run dashboard.yaml
- Via ESPHome Dashboard: upload
-
Add template sensors — copy the contents of
templates.yamlinto your Home Assistantconfiguration.yamlunder thetemplate:key:template: - trigger: # ... (paste contents of templates.yaml here)
-
Restart Home Assistant to activate the template sensors
New to Home Assistant or ESPHome? Here's a step-by-step walkthrough.
ESPHome is a tool that lets you configure ESP32/ESP8266 microcontrollers using simple YAML files. It integrates natively with Home Assistant — no custom firmware coding required.
- In Home Assistant, go to Settings → Add-ons → Add-on Store
- Search for ESPHome and install it
- Start the add-on and open the Web UI
- Go to Settings → Devices & Services → Add Integration
- Search for Meteorologisk institutt (Met.no) (or your preferred weather provider)
- Configure it with your home coordinates
- Note the entity name (e.g.,
weather.forecast_home)
Entity IDs are how Home Assistant identifies each sensor, switch, or device.
- Go to Developer Tools → States (in your HA sidebar)
- Use the search/filter to find sensors you want to display
- The entity ID looks like
sensor.living_room_temperature - Set your entity IDs in the
substitutions:block at the top ofdashboard.yaml
- Connect the Waveshare e-Paper to the LOLIN S3 Pro using the pin mapping table
- Connect via USB-C to your computer
- If using a battery, connect it to the LOLIN's JST battery connector
- In the ESPHome Dashboard, click + New Device
- Instead of creating from scratch, upload the
dashboard.yamlfile - Make sure
fonts/is in the correct directory - Click Install → Plug into this computer (for first flash via USB)
- Subsequent updates can be done wirelessly (OTA)
The templates.yaml file creates sensors that process weather forecast data into a format the dashboard can display.
- Open your Home Assistant
configuration.yaml - Add a
template:section (if it doesn't exist) - Paste the contents of
templates.yamlunder it - Go to Developer Tools → YAML → Template Entities → Reload
All entity IDs and display labels are configured in the substitutions: block at the top of dashboard.yaml. You only need to edit that one block — no need to touch individual sensor definitions.
| Substitution variable | Example value | Purpose | Required? |
|---|---|---|---|
ha_weather_entity |
weather.forecast_home |
Weather entity (temperature, condition, UV) | Yes |
ha_outdoor_temp_entity |
sensor.garden_temperature |
Outdoor temperature | No |
ha_outdoor_humidity_entity |
sensor.garden_humidity |
Outdoor humidity | No |
ha_outdoor_pressure_entity |
sensor.garden_pressure |
Atmospheric pressure | No |
ha_outdoor_air_entity |
sensor.garden_pm2_5 |
PM2.5 air quality | No |
ha_indoor_temp_entity |
sensor.living_room_temperature |
Indoor temperature | No |
ha_indoor_humidity_entity |
sensor.living_room_humidity |
Indoor humidity | No |
ha_tasks_entity |
sensor.open_tasks |
Open tasks count | No |
ha_fuel_price_entity |
sensor.fuel_price_super |
Fuel price (€) | No |
ha_currency_entity |
sensor.eur_pln |
Exchange rate | No |
ha_ev_battery_entity |
sensor.ev_battery_level |
EV battery (%) | No |
ha_ev_range_entity |
sensor.ev_range |
EV electric range (km) | No |
ha_fuel_range_entity |
sensor.fuel_driving_range |
Fuel range (km) | No |
label_outdoor_sensor |
"Outdoor sensor" |
Label shown on display | No |
label_fuel_station |
"Fuel station" |
Station name on display | No |
label_fuel_closed |
"Station closed" |
Text when no fuel price data | No |
label_car_name |
"Car:" |
Car section label on display | No |
All entity IDs are set in the substitutions: block at the top of dashboard.yaml:
substitutions:
ha_outdoor_temp_entity: sensor.garden_temperature # ← put YOUR entity ID here
ha_indoor_temp_entity: sensor.living_room_temperature
# ... etc.You do not need to touch the individual - platform: homeassistant sensor blocks — they all reference the substitution variables.
How to find your entity ID in Home Assistant:
- Go to Developer Tools → States
- Filter by type (e.g., type "temperature")
- Copy the
entity_idvalue (e.g.,sensor.garden_temperature) - Paste it as the value of the corresponding substitution variable in
dashboard.yaml
To display a new piece of data, you need to do three things:
1. Add the sensor block in the sensor: section of dashboard.yaml:
- platform: homeassistant
id: my_new_sensor # pick a unique ID
entity_id: sensor.your_entity # your HA entity
internal: true
unit_of_measurement: "°C" # optional — match your sensor's unit2. Add rendering code in the display lambda: section. Find a spot where you want it and add:
// My new sensor
if (!std::isnan(id(my_new_sensor).state))
it.printf(x, y, id(font_small_bold), TextAlign::TOP_LEFT, "%.1f°C", id(my_new_sensor).state);
else
it.printf(x, y, id(font_small_bold), TextAlign::TOP_LEFT, "b/d");Replace x, y with pixel coordinates (the display is 480 wide × 800 tall after rotation).
3. For text sensors (non-numeric), use text_sensor: instead of sensor::
text_sensor:
- platform: homeassistant
id: my_text_sensor
entity_id: sensor.your_text_entity
internal: trueAnd in the lambda:
it.printf(x, y, id(font_small), TextAlign::TOP_LEFT, "%s", id(my_text_sensor).state.c_str());To remove something you don't need (e.g., EV/car section, fuel price, tasks), delete three things:
1. The sensor definition — find and delete the whole - platform: homeassistant block:
# Delete this entire block:
- platform: homeassistant
id: ev_battery
entity_id: ${ha_ev_battery_entity}
internal: true
unit_of_measurement: "%"2. The rendering code — find the corresponding section in the display lambda: and delete it. Look for the id(ev_battery) reference. For the car section, delete from the separator line (it.line(right_col_x, ...)) through the fuel_range printf.
3. Optionally, remove the now-unused substitution variables from the substitutions: block at the top of the file.
Tip: Use your editor's search to find all references to the sensor's
id(e.g., search forev_battery) to make sure you delete everything.
Calendars are configured in two places:
1. templates.yaml — defines which HA calendars to fetch events from:
entity_id:
- calendar.home # ← replace with your main calendar
- calendar.vacations # ← replace or remove
- calendar.public_holidays # ← replace or removeTo add a calendar, add another - calendar.your_calendar line and add a matching variable:
- variables:
events_home: "{{ agenda['calendar.home']['events'] | default([]) }}"
events_vacations: "{{ agenda['calendar.vacations']['events'] | default([]) }}"
events_holidays: "{{ agenda['calendar.public_holidays']['events'] | default([]) }}"
events_new: "{{ agenda['calendar.your_calendar']['events'] | default([]) }}" # ← add
events_all: "{{ events_home + events_vacations + events_holidays + events_new }}" # ← includeTo remove a calendar, delete its entity_id line, its variable, and remove it from events_all.
2. dashboard.yaml — the calendar rendering code in the display lambda has special logic for:
- Work shifts — events containing "früh"/"spät"/"nacht" are mapped to Polish shift names
- Vacations — events containing "urlop"/"urlaub" suppress shift display and show "Urlop" instead
- Trash collection — events containing "wywóz"/"śmieci" get a trash icon
To change these keywords, search for them in the analyze_day lambda function and modify to match your calendar event names.
In dashboard.yaml, you can modify:
deep_sleep:
run_duration: 90s # How long the ESP stays awake
sleep_duration: 20min # How long between wake-upsShorter sleep_duration = more frequent updates but shorter battery life.
The templates.yaml file provides template sensors that:
-
Forecast sensors — extract daily forecast data (temperature, conditions, date) from your weather integration. The dashboard reads these via the ESPHome HA API.
-
Calendar aggregator — merges events from 3 calendars into a single
sensor.upcoming_eventsentity with a structuredeventsattribute. It fetches 2 days of events to reliably cover both today and tomorrow.
Replace these calendar entity IDs in templates.yaml with your own:
entity_id:
- calendar.home # ← Your main calendar
- calendar.vacations # ← Vacation/leave calendar
- calendar.public_holidays # ← Holidays calendarReplace the weather entity in templates.yaml (two occurrences — in the trigger and in the action):
entity_id: weather.forecast_home # ← replace with your weather entityAnd update all forecast references (forecast['weather.forecast_home']) to match:
state: "{{ forecast['weather.your_entity'].forecast[0].temperature }}"| Parameter | Value |
|---|---|
| Wake duration | 90 seconds |
| Sleep duration | 20 minutes |
| Wakeup pin | GPIO1 (keeps awake when active) |
| Display power | GPIO15 (turned off before sleep) |
| Battery ADC | GPIO3 (×2 multiplier, 12 dB attenuation) |
| Battery range | 3.0 V (0%) — 4.2 V (100%) |
| Deep sleep switch | GPIO2 (slide switch, optional) |
The display power is managed via GPIO15 — it is turned on at boot and turned off on shutdown to minimize current draw during sleep.
The SS-12D10G5 slide switch connected to GPIO2 controls whether the device is allowed to enter deep sleep:
| Switch position | Effect |
|---|---|
| ON | Normal operation — device enters deep sleep for 20 minutes after each update |
| OFF | KEEP_AWAKE — deep sleep is disabled; device stays awake indefinitely |
The OFF position is useful during development, serial monitoring, or OTA firmware updates — when the device is in deep sleep most of the time, OTA is only possible within the 90 s wake window. Flipping the switch to OFF keeps it reachable permanently. Flip back to ON for normal battery-powered operation.
| Problem | Solution |
|---|---|
| Blank display after boot | Check SPI wiring. Verify busy_pin is inverted. Check GPIO15 power output. |
| Display shows no data / all fields "b/d" | The device booted but couldn't receive data from HA within the timeout. Check WiFi credentials in secrets.yaml. Verify HA is reachable and the API is enabled. Check ESPHome logs for connection errors. |
| Some data shows "b/d" (no data) | The corresponding HA sensor entity doesn't exist or has no state. Check entity IDs. |
| Battery drains fast | Reduce run_duration. Check that the display power is off during sleep. Verify deep sleep is actually entering (check logs). |
| Compilation error about missing fonts | Make sure the fonts/ directory is in your ESPHome config folder alongside dashboard.yaml. |
| OTA update fails | The device is in deep sleep most of the time. Use the wakeup pin or catch it during the 90 s wake window. Flash via USB if needed. |
- Inspired by Weatherman Dashboard by @Madelena
- Fonts: Google Noto Sans, Material Design Icons
- PCB design: WR Electro (EasyEDA, schematic V1.3)
This project is licensed under the MIT License.




