Home Assistant : Solar Self-consumption Monitoring

Background

I have solar panels on my roof (I already did a nice project on that topic), but you already know it. The idea is to use the generated electricity immediately to avoid sending it back to the grid (cost involved). So, I decided to create a little project to show to the people living in my house if this is the best time to use energy-intensive appliances.

The project

It all started with a Claude prompt to try to get an idea of what information I can provide to the family. Here is what he did : 


Not bad for a first attempt. But, still, we cannot see the most important information: should I plug in the dishwasher? Instead, there is information that I do not need like the temperature of some irrelevant rooms and the agenda, I do not need that.

However, when looking at this dashboard, I immediately thought about 3-color ePaper. Yes! This is what I need, a big enough ePaper and some cool frame to display that information to anyone passing by the frame. Go! Let's quickly order a 7.5 inch 3-color ePaper display while continuing this project.

After a few iterations, I have found the ideal dashboard, here it is :


A clear view about what I have produced today and what I have sent to the grid, the ratio between self-consumption, the near realtime production, summary of the grid for the current day, what tariff is current (peak/off peak), outside temperature, and the most important, what is the extra power I can use at the moment.

Home Assistant

Since Home Assistant already contains all relevant information, it's the right time to "mimic" this display into a HA dashboard, making sure all relevant details exist.

Here is the sample dashboard that collects all sensors and formulas to re-create the ePaper dashboard:


Now that I have all fields, this is time to start working on the ePaper controller. So, you get the idea, I'm going to use the ePaper display as a display for some Home Assistant sensors, this will be refreshed every X minutes but, the real intelligence is not the ePaper display itself, this is Home Assistant. I'm actually creating a remote display for some specific fields in a specific format, nothing more, nothing less.

MicroPython

I decided to use MicroPython since this is the easiest language for micro controller such as the one controlling the ePaper display. This is an ESP32 with GPIO ports with integrated WiFi capabilities.

I tried with the original driver/examples provided by the manufacturer but is was full of errors and hardcoded paths related to Windows (I'm running on a Mac), so I decided to give up and try a different approach, from very basic to the finished product.

Here are the command line steps on a MacBook device, any Linux is probably working the same way : 

$ pip3 install --user esptool mpremote --break-system-packages

Plug the ESP32 to your USB port, approve the connection at the MacOS level (prompt with Deny/Allow) and try to determine what device it is : 

$ ls -1 /dev/cu.usbmodem*
/dev/cu.usbmodem5ABA0258441  
/dev/cu.usbmodemR5CY84EC7ZT2

The correct device for me was /dev/cu.usbmodem5ABA0258441. How did I know it ? I tried the other one and it failed ;)

Download the MicroPython binary for ESP32 from this website : https://micropython.org/download/ESP32_GENERIC/. It should be the latest release with a name similar to ESP32_GENERIC-20260406-v1.28.0.bin.

Now, this is time to erase the ESP32 flash : 

$ python3 -m esptool --chip esp32 --port /dev/cu.usbmodem5ABA0258441 erase-flash

esptool v5.2.0
Connected to ESP32 on /dev/cu.usbmodem5ABA0258441:
Chip type:          ESP32-D0WD-V3 (revision v3.1)
Features:           Wi-Fi, BT, Dual Core + LP Core, 240MHz, Vref calibration in eFuse, Coding Scheme None
Crystal frequency:  40MHz
MAC:                3c:8a:1f:b1:7d:00

Stub flasher running.

Flash memory erased successfully in 13.5 seconds.

Hard resetting via RTS pin...

Now, you can flash the ESP32 with MicroPython : 

$ python3 -m esptool --chip esp32 --port /dev/cu.usbmodem5ABA0258441 --baud 460800 write-flash -z 0x1000 ./ESP32_GENERIC-20260406-v1.28.0.bin

esptool v5.2.0
Connected to ESP32 on /dev/cu.usbmodem5ABA0258441:
Chip type:          ESP32-D0WD-V3 (revision v3.1)
Features:           Wi-Fi, BT, Dual Core + LP Core, 240MHz, Vref calibration in eFuse, Coding Scheme None
Crystal frequency:  40MHz
MAC:                3c:8a:1f:b1:7d:00

Stub flasher running.
Changing baud rate to 460800...
Changed.

Configuring flash size...
Flash will be erased from 0x00001000 to 0x001aefff...
Wrote 1760192 bytes (1152806 compressed) at 0x00001000 in 28.1 seconds (501.5 kbit/s).
Hash of data verified.


Hard resetting via RTS pin...

The ultimate test is :

$ python3 -m mpremote connect /dev/cu.usbmodem5ABA0258441

You should see something like this :

Connected to MicroPython at /dev/cu.usbmodem5ABA0258441 Use Ctrl-] or Ctrl-x to exit this shell
>>>

Bingo ! You are in ;)

To exit this shell use  Ctrl-] or Ctrl+x

The software architecture

Now that the ESP32 runs MicroPython, let's look at the code that turns it into a dashboard.

The project ended up split into 5 MicroPython modules that talk to each other. Each module has one clear responsibility — this makes debugging much easier, because when something breaks you immediately know which file to open.

/ (ESP32 flash root)

├── main.py             ← orchestrates the full cycle 
├── epaper_ha_client.py ← talks to Home Assistant REST API 
├── display.py          ← UC8179 driver + dashboard layout in FR
├── display_en.py       ← UC8179 driver + dashboard layout in EN
├── battery.py          ← MAX17048 fuel gauge over I²C 
├── config.json         ← WiFi credentials + HA URL/token 
└── barlow_bold_56.py   ← compiled font (and 3 more) 
    barlow_bold_40.py 
    barlow_bold_28.py 
    archivo_bold_13.py


These files can be found on my Github repo.

To transfer the files to the ESP32, issue the following command, assuming the files are in the current folder :

Here is the mpremote command to push all files at once on the ESP32 :

# Push all files to the ESP32 flash root

$ python3 -m mpremote cp \

    main.py \
    epaper_ha_client.py \
    display.py \
    display_en.py \
    battery.py \
    config.json \
    barlow_bold_56.py \
    barlow_bold_40.py \
    barlow_bold_28.py \
    archivo_bold_13.py \
    :

# Verify $ python3 -m mpremote ls

Do not omit the ":" at the end, this is specifying the target for the files.

Note : If you want the English dashboard instead, rename display_en.py to display.py before copying (or delete the French version on the device after the copy).

# For English UI on the device: 
$ python3 -m mpremote rm :display.py 
$ python3 -m mpremote cp display_en.py :display.py

The cycle (main.py)

Every 10 minutes the ESP32 wakes from deep sleep and runs the same sequence :

  1. Pre-allocate the framebufferbytearray(48000) as the very first thing, before any other import
  2. Connect WiFi — read config.json, attempt 3 retries
  3. Fetch HA data — REST calls to read ~14 sensors and the production history
  4. Release WiFi — give back ~40 KB of RAM to the system
  5. Read battery — query the MAX17048 fuel gauge via I²C
  6. Render the dashboard — compose the 2 planes (black + red), send to ePaper
  7. Deep sleep for the next 10 minutes
When the ESP32 wakes up, it doesn't resume — it fully restarts from scratch. This sounds inefficient, but it's actually a feature : a fresh boot means no memory fragmentation, no stale WiFi state, no leaks. The trade-off is the 2-3 seconds of boot time, which is dwarfed by the 20 seconds the ePaper needs to refresh anyway.

Home Assistant integration

Home Assistant exposes a clean REST API that lets external clients read the state of any sensor, fetch history, and even trigger actions. For this project I only need read access — the ESP32 never writes anything back to HA.

Getting an authentication token

In HA, go to your user profile → Long-lived access tokens → Create token. Give it a name like epaper-dashboard, copy the token (a long string starting with eyJ...), and store it. HA only shows it once — if you lose it, you'll have to create a new one.

⚠️ Treat the token as a password. It grants full read access to your entire HA instance. Never commit it to git.

The token, the WiFi credentials, and the HA URL all go into config.json on the ESP32 flash:

{
  "wifi_ssid": "YourNetwork",
  "wifi_pass": "YourPassword",
  "ha_url":    "http://192.168.x.y:8123",
  "ha_token":  "eyJhbGciOiJIUzI1NiIsInR5cCI6........"
}

Push it to the device with:
$ python3 -m mpremote cp config.json :

Reading a sensor

The REST endpoint to read one sensor is:

GET /api/states/<entity_id>
Authorization: Bearer <ha_token>
For example, GET /api/states/sensor.pv_production_today returns a JSON document like:
{
"entity_id": "sensor.pv_production_today", "state": "14.23", "attributes": { "unit_of_measurement": "kWh", ... }, "last_changed": "2026-06-06T14:32:11.000Z" 
}

In MicroPython, fetching that becomes:
import urequests

HEADERS = {"Authorization": "Bearer " + TOKEN}

def get_state(entity_id):
    r = urequests.get(HA_URL + "/api/states/" + entity_id, headers=HEADERS)
    state = r.json()["state"]
    r.close()                    # always close to free the socket!
    return state

pv_today = float(get_state("sensor.pv_production_today"))
💡 Always call r.close() after reading the response. MicroPython has a tiny pool of sockets — leaking one will block subsequent requests.

The dashboard needs about 14 sensors (PV production, grid export, daily cost, outdoor temperature, sunrise/sunset, current tariff, etc.). Rather than hard-coding entity IDs throughout the codebase, I collected them all in one dictionary at the top of epaper_ha_client.py:

ENT = {
    "pv_today":   "sensor.pv_production_today",
    "pv_now":     "sensor.pv_production_now",
    "inj_today":  "sensor.grid_export_today",
    "tarif":      "sensor.current_electricity_tariff",
    "t_ext":      "sensor.outdoor_temperature",
    "lever":      "sensor.sunrise",
    "coucher":    "sensor.sunset",
    # ... 7 more
}

This way, swapping a sensor (different brand, different naming) means editing one line.

The histogram — fetching production over time

A bare list of "today's totals" isn't very informative. What I really want is the shape of today's production : was the morning sunny? Did a cloud pass at 2 PM? Where am I on the daily curve?

HA exposes a history endpoint that returns all data points logged for a sensor within a time window:

GET /api/history/period/<iso_timestamp>?filter_entity_id=<entity>

    &minimal_response
    &significant_changes_only
    &no_attributes

The 3 query parameters at the end matter:

  • minimal_response — strips redundant fields, smaller JSON
  • significant_changes_only — only points where the value actually changed
  • no_attributes — we only need state and last_changed

For a 12-hour window I typically get 30-50 data points. The challenge is that these points are irregularly spaced (HA only logs when the value changes by more than a threshold). To draw a clean histogram of 24 half-hour buckets, I need to interpolate.

The trick exploits the fact that the production sensor is cumulative (kWh since midnight) and monotonic (only goes up, except at midnight reset) :

  1. For each 30-minute boundary in the last 12 hours, find the two data points surrounding it
  2. Linearly interpolate between them to estimate the cumulative value at that exact moment
  3. The production during a 30-minute slot is the difference between two consecutive interpolated boundaries

def interpolate_at(timestamp, points):
    """Linear interpolation between the 2 surrounding points."""
    for i in range(len(points) - 1):
        t1, v1 = points[i]
        t2, v2 = points[i + 1]
        if t1 <= timestamp <= t2:
            return v1 + (v2 - v1) * (timestamp - t1) / (t2 - t1)
    return points[-1][1]   # fallback to last known
💡 Why interpolate — Without it, "the last point before the boundary" introduces up to 25% error per bucket. Interpolation makes the histogram match what you'd see on the HA chart almost exactly.

A surprise time-zone gotcha

The sunrise and sunset times displayed in my dashboard kept showing up 2 hours off during summer. The culprit was the HA template that computes them:

# WRONG — outputs UTC
template:

  - sensor:
      - name: "Sunrise"
        state: "{{ state_attr('sun.sun','next_rising')[11:16] }}"
The next_rising attribute is stored in UTC. Slicing the string directly gives you the UTC hour. The fix is to convert to local time first using HA's as_local filter, which automatically handles daylight saving time :
# RIGHT — outputs local time, DST-aware
template:
  - sensor:
  - name: "Sunrise"
       state: >
       {% set t = as_datetime(state_attr('sun.sun','next_rising')) | as_local %}
       {{ t.strftime('%H:%M') }}

No ESP32 code change needed — at the next refresh cycle, the correct values appear automatically.


Bill of Materials

Total cost: roughly 70-90 € depending on your suppliers and what you already have lying around.

Electronics

  • Tricolor ePaper display + driver board — Waveshare 7.5" V2 (black/white/red), 800×480 px, shipped together with the ESP32 driver board. ~65 € on Amazon FR. Make sure you select the V2 version when ordering — older revisions use a different controller chip and the code in this project won't work with them.
  • LiPo battery — HXJNLDC 7565121, 1S 3.7V, 8200 mAh, with built-in PCM (protection circuit module) and pre-soldered wires. ~12 € on Amazon FR. The size (75×65×12 mm) fits nicely behind a 17×22 cm photo frame. 
  • LiPo charger + 5V boost — Seeed Lipo Rider Plus. ~15 € on Kiwi Electronics. Charges the LiPo via USB-C and boosts the 3.7V cell voltage to a stable 5V for the ESP32 — two functions in one tiny board. The 2.4A boost output has plenty of headroom for the ePaper refresh peak.
  • Battery fuel gauge — Adafruit MAX17048 (part 5580). ~12 € on Kiwi Electronics. I²C battery monitor that gives accurate %, voltage, and charge rate. Optional but highly recommended — without it, you have no way to know if your LiPo is at 80% or about to die. 💡 The chip is ESD-sensitive — order two if you can afford to. A spare will save you a 3-day shipping wait when you (inevitably) zap one with a careless wiring move.
  • JST-PH adapter cable (optional) — 2-pin JST-PH male-to-male, ~10 cm. Around 2 €. Useful if your LiPo's polarity doesn't match the charger's expected polarity (you'll see this in the hardware section).
  • Dupont jumper wires — Female-to-female, ~10 cm. A few cents each. You need 4 wires for the I²C bus between the fuel gauge and the ESP32 driver board.
  • USB-A to USB-C cable — Short (~15 cm), data-capable. Around 3 €. Connects the boost output to the ESP32 input. A short cable keeps things tidy inside the frame.
  • USB-C charger — Any 5V/2A wall adapter. ~5-10 €. Only used occasionally to recharge the LiPo.
None of these are affiliate links — just the shops I personally used for this project.

Enclosure

  • Photo frame — Wooden or MDF, with at least 17×22 cm interior, ≥ 25 mm depth, and a removable back. 2-15 € from any furniture store or supermarket. I used a basic 2 € wooden frame and it works beautifully.
  • Passe-partout (mat board) — Off-white or cream, cut to expose the active area of the panel (163×98 mm). Around 2-5 €. Adds a clean visual finish — hides the panel borders and gives a professional look.
  • Double-sided foam tape — 3M-style, ~1 mm thick. Around 3 €. To stick the LiPo and small boards inside the frame.

Tools (one-time)

  • Soldering iron + solder — Any basic 30W iron will do. Needed to fix the polarity adapter cable and (optionally) to solder pin headers on the Lipo Rider Plus.
  • Multimeter — A basic 10 € meter from Amazon is sufficient. Essential for voltage and polarity checks before plugging the LiPo into anything — saves you from frying a chip the hard way.
  • Heat shrink tubing — A small assortment kit (~5 €) lasts years. Used to insulate exposed solder joints.
  • USB cable to the ESP32 — Most projects use USB-C nowadays. You'll need it to flash the firmware and push your code from your computer.

Software (free)

  • MicroPython firmware for the ESP32 — download from micropython.org.
  • esptool to flash the firmware — pip install esptool.
  • mpremote to copy files and run code from your computer — pip install mpremote.
  • A Home Assistant instance, with a long-lived access token.

What I'd avoid

A few things I tried first and would recommend skipping:

  • A USB power bank to power the ESP32 in deep sleep — they all auto-shutoff when the current draw drops below ~50 mA, so the ESP32 dies at the first deep sleep. Use a dedicated LiPo + boost setup instead.
  • A bare TP4056 charger module without a boost — it charges the LiPo but doesn't output 5V to power the ESP32. You'd need a separate boost converter and lose efficiency on the wiring. The Lipo Rider Plus combines both functions and is much cleaner.
  • A 7.5" ePaper without the "V2" tag — older revisions use a different driver chip and the code in this project won't work out of the box.

The Connections

Now that the parts are listed, let's see how they all connect together. Here is the full wiring diagram:


The diagram is split into two halves:

  • Top — signal and data connections: the ePaper panel talks to the ESP32 via SPI over a flat FFC ribbon (6 signals). The MAX17048 fuel gauge talks to the ESP32 via I²C (4 wires : VIN, GND, SDA, SCL).
  • Bottom — power chain: the LiPo flows through a polarity adapter, then the MAX17048 (which measures it via JST passthrough), then a JST inverter cable, and finally into the Lipo Rider Plus, whose boosted 5V output powers the ESP32 via a short USB-A to USB-C cable.

Final Product

The time has finally come to show the final product with all the components fitted together!

It was a real challenge to fit all the parts behind the frame, but I must admit this is nice enough for a beta/demo/usable product right now. 


It stands proudly on the countertop and every family member can glance at it while making coffee — exactly the spot I had in mind from day one.

What I learned

Looking back at this project from idea to finished frame, a few takeaways stand out:

  • Tricolor ePaper is gorgeous in person — the red ink really pops on the off-white background, and the matte finish feels like printed paper rather than a screen. Worth every euro.
  • MicroPython on ESP32 is more capable than I expected — the 165 KB heap feels tight at first, but with discipline (pre-allocate buffers, lazy imports, release WiFi early) you can do surprisingly rich applications.
  • The real magic is in Home Assistant — the dashboard is "just" a remote display, but because HA exposes everything via a clean REST API, anyone can adapt this project to whatever sensors they care about. Solar today, washing machine state next, fridge temperature after that.
  • A 2 € photo frame beats a 3D-printed enclosure — I almost went down the rabbit hole of designing a custom case in OpenSCAD. Glad I didn't. A wooden frame with a passe-partout looks classier and takes 5 minutes instead of 5 hours.
I'm curious if anyone else is keen on replicating this build ! let me know in the comments below.

I hope this helps !





Comments

What's hot ?

Nutanix : CVM stuck into Phoenix