e-paper price tag next calendar event

Here I'm documenting the steps (in varying level of detail) for posterity and others to get an e-paper price tag with the next event of my day merging multiple calendars together.

This is what the end result looks like (that's a 2.9” epaper display and looks so much nicer in-person) :

ePaper pricetag showing Next Event Today with Start and End time and line of text for the event description

So what do you need to make that happen?

Calendars in Home Assistant

Google Calendar

Where I work uses Google Calendar and have not disabled the iCal feed. Where you work may use something different, and also you might want to check with someone if it's against policy to have calendar entries exported.

To get that Google Calendar into Home Assistant is relatively simple, you add the ICS Calendar integration, give it the “Secret address in iCal format” from the settings of the calendar, name it something like “Work” and done.

NextCloud Calendar

I use NextCloud for my personal calendar so I don't miss out on things like game night.

Again, simple integration, this time the CalDAV one. Go into the Calendar app on NextCloud, click settings and scroll down until you get to “Copy primary CalDAV address”. Enter that, along with credentials into HomeAssistant, and calendar is set up.

Others

There are integrations for other calendars but I don't use them, so YMMV, but worth a shot.

OpenEPaperLink Access Point

Okay, this takes a little explaining. OpenEPaperLink (henceforth OEPL) is a combination of custom firmware for various price tags/ePaper displays and an Access Point for sending data to them.

Generally, these use the same radio (and therefore channels in the 2.4Ghz spectrum) as ZigBee (802.15.4) but without the ZigBee protocol layer. Yes, that means your existing ZigBee setup won't help you here.

Note: I'm ignoring the rarer price tag types, which need another radio (known as subghz) and bluetooth-only tags which to my understanding are currently problematic for the AP.

To make things easy, OEPL has created multiple types of access point. Usually this is an ESP32-S3 coupled with an ESP32-C6. You need both pieces to make an AP because you need to be talking wifi (s3) and 802.15.4 (c6) at the same time. I'm actually using a much older AP based on the ESP32-S2 and a spare segmented tag which is acting in place of the ESP32-C6, but that's not getting any updates now.

Choices are here : – The easiest (but definitely not the cheapest) AP is the LilyGo T-Panel AP. – If you're not so good at soldering, and want a cheap option you can use dupont cables and make the Spaghetti AP. – If you want a cheap but nice option and don't mind ordering PCBs and a bit of soldering, the Yellow AP is probably for you.

Bear in mind this blog post was written in February 2025, so you might want to browse the OEPL github wiki to see if things have changed here.

Once you have an AP, connect it to your network and it will talk to any flashed tags you have.

E-paper price tag

I'm using the most common tag, manufactured by Solum, the ST‐GR29000. There are a lot of tags that have OpenEPaperLink (OEPL) firmware, and getting them is sometimes hard. eBay sometimes has them, aliexpress rarely, and the discord (check the homepage for a current link) that's used for OEPL has some trading because when people find a tag they like, they often buy in bulk, and have spares.

The tags come in different shapes and sizes, the code on this page is for a 2.9” tag at a resolution of 296x128px. It should work for any tag that is the same resolution, or you might want to tweak the positions of things if you have a different resolution.

Also fun to know, ePaper usually comes in Black and White; Black, White and Red or Black White and Yellow. I like BWR myself, and if you only have a Black and White tag, you might need to change the Red to Black in the code, not sure what the integration would do if you sent red to a Black&White tag. I don't know if OEPL supports more colours (the integration doesn't) as colour ePaper screens are still quite rare.

Mine came pre-flashed with the OEPL firmware because they were used by someone else before. If you have to flash more than one, you probably want to make a jig for it.

Fun with templates

Once you have all the above, install the OpenEPaperLink (OEPL) integration. You can get this via HACS, which I recommend, and doesn't require your HA be connected to the “My Home Assistant” Cloud stuff.

Home Assistant uses the Jinja templating language to template text. The OEPL integration provides a new action called DrawCustom that you can call from an automation and allows you to describe in YAML form what you want to draw. That's a choice (one that the HA ecosystem leaves you with little choice in) but we work with what we have.

One of the people on the aforementioned discord (check the homepage for a current link) posted an agenda view automation and combined their calendars, and I iterated on that to provide this one.

Here's the juicy YAML:

alias: Next Event
description: ""
triggers:
  - hours: /1
    trigger: time_pattern
    minutes: /5
conditions:
  - condition: time
    after: "05:00:00"
    before: "23:00:00"
actions:
  - alias: Get events 0 day(s) from now (filtered out past events)
    data:
      start_date_time: "{{ now() }}"
      end_date_time: "{{today_at('00:00') + timedelta(days=1)}}"
    response_variable: cal_day_0
    target:
      entity_id:
        - calendar.personal
        - calendar.work
    action: calendar.get_events
  - alias: Get events 1 day(s) from now
    data:
      start_date_time: "{{ today_at('00:00') + timedelta(days=1) }}"
      end_date_time: "{{ today_at('00:00') + timedelta(days=2) }}"
    response_variable: cal_day_1
    target:
      entity_id:
        - calendar.personal
        - calendar.work
    action: calendar.get_events
  - alias: Get events 2 day(s) from now
    data:
      start_date_time: "{{ today_at('00:00') + timedelta(days=2) }}"
      end_date_time: "{{ today_at('00:00') + timedelta(days=3) }}"
    response_variable: cal_day_2
    target:
      entity_id:
        - calendar.personal
        - calendar.work
    action: calendar.get_events
  - alias: Get events 3 day(s) from now
    data:
      start_date_time: "{{ today_at('00:00') + timedelta(days=3) }}"
      end_date_time: "{{ today_at('00:00') + timedelta(days=4) }}"
    response_variable: cal_day_3
    target:
      entity_id:
        - calendar.personal
        - calendar.work
    action: calendar.get_events
  - alias: Extract events from all calendars
    variables:
      header: Next Event
      timeheaderstart: Start
      timeheaderstartx: 266
      timeheaderend: End
      timeheaderendx: 272
      events_day0: >
        {{ cal_day_0.values() | map(attribute='events') | list |
        sum(start=[])}} 
      events_day1: >
        {{ cal_day_1.values() | map(attribute='events') | list |
        sum(start=[])}} 
      events_day2: >
        {{ cal_day_2.values() | map(attribute='events') | list |
        sum(start=[])}} 
      events_day3: >
        {{ cal_day_3.values() | map(attribute='events') | list |
        sum(start=[])}} 
      days:
        - Today
        - Tomorrow
        - Ubermorgen
        - 3 Days away
  - alias: Send generated Event to OEPL
    metadata: {}
    data:
      rotate: 0
      dry-run: false
      background: white
      payload:
        - type: text
          value: |
            {{ header}}
          font: ../../media/OpenSans-Bold.ttf
          x: 10
          "y": 10
          size: 20
          color: black
        - type: text
          value: |
            {% if events_day0 | count > 0 %}
              {{ days[0] }}
            {% elif events_day1 | count > 0 %}
              {{ days[1] }}
            {% elif events_day2 | count > 0 %}
              {{ days[2] }}
            {% else %}
              {{ days[3] }}
            {% endif %}
          font: ../../media/OpenSans-Bold.ttf
          x: 10
          "y": 50
          size: 28
          color: red
        - type: text
          value: |
            {%- set events = [{}] %}    
            {% if events_day0 | count > 0 %}
              {%- set events = events_day0 | sort(attribute='start') | list %}
            {% elif events_day1 | count > 0 %}
              {%- set events = events_day1 | sort(attribute='start') | list %}
            {% elif events_day2 | count > 0 %}
              {%- set events = events_day2 | sort(attribute='start') | list %}
            {% elif events_day3 | count > 0 %}
              {%- set events = events_day3 | sort(attribute='start') | list %}
            {% else %}
              {%- set events = [{}] %}
            {% endif %}
            {%- set event = events[0] %}
            {%- if 'start' in event -%}
                {% if as_timestamp(event.start) | timestamp_custom('%H:%M') == "00:00"%}
                  All day:  {{ event.summary }}
                {% else %}
                  {{ event.summary }}
                {% endif %}
            {%- endif -%}
          font: ../../media/OpenSans-Bold.ttf
          "y": 105
          size: 14
          x: 10
        - type: text
          value: |
            {{ timeheaderstart }}
          font: ../../media/OpenSans-Bold.ttf
          x: |
            {{ timeheaderstartx }}
          "y": 5
          size: 11
          color: red
        - type: text
          value: |
            {{ timeheaderend }}
          font: ../../media/OpenSans-Bold.ttf
          x: |
            {{ timeheaderendx}}
          "y": 55
          size: 11
          color: red
        - type: multiline
          delimiter: §
          font: ../../media/OpenSans-Bold.ttf
          value: |
            {%- set events = [{}] %}    
            {% if events_day0 | count > 0 %}
              {%- set events = events_day0 | sort(attribute='start') | list %}
            {% elif events_day1 | count > 0 %}
              {%- set events = events_day1 | sort(attribute='start') | list %}
            {% elif events_day2 | count > 0 %}
              {%- set events = events_day2 | sort(attribute='start') | list %}
            {% elif events_day3 | count > 0 %}
              {%- set events = events_day3 | sort(attribute='start') | list %}
            {% else %}
              {%- set events = [{}] %}
            {% endif %}
            {%- set event = events[0] %}
            {%- if 'start' in event -%}
                {{- as_timestamp(event.start) | timestamp_custom('%H:%M') }}§{{as_timestamp(event.end) | timestamp_custom('%H:%M') }}
            {% endif -%}
          start_y: 32
          x: 190
          size: 40
          offset_y: 50
          color: black
    action: open_epaper_link.drawcustom
    target:
      device_id: b173ed2bf21ca8444c86afb61ef222e1
mode: single

You'll need to grab the font and put it in your Home Assistant media directory (mine is /config/media but your setup might be different).

I've made some config variable in Extract events from all calendars like the heading, and words for Today/Tomorrow/Übermorgen/In 3 days (I love the word Übermorgen), and you can left-align Start/End to the time by setting their x values in there to 190 if you prefer that look.

Comments on this blog post can be sent direct on the fediverse (any activitypub server like mastodon etc.) on @martyn@toot.martyn.berlin.