Openepaperlink is an awesome way of repurposing Electronic Shelf Labels. The awesome work by the community has allowed using them to display anything you like.

Home Assistant is my favorite Domotica solution so using the info from the numerous sensors placed all around my house to be displayed on the nice ePaper tags felt like a nobrainer. Apparently I was not the only one because the amazing Jonas Niesner created a HA Plugin just with that in mind. In no time I had several info tags to place around the house:

Since I also bought a larger display (4.2″) I wanted to show the coming appointments on it so I wouldn’t have to take out my phone. Since I already had the tags connected to my Home Assistant I decided to try and use that as a central hub for this.
Adding an Office 365 Calendar to HA
Since my personal calendars are not Google based but hosted on Office 365 (which in true Microsoft style is not very open) I needed to do some extra work to get my calendar visible in HA. These are the steps I needed to take:
- Log in to Outlook.
- Open settings and go to Calendar –> Shared Calendars.
- Select the Calendar you want to share and click Publish
- Copy the ICS link that’s generated. (note that anyone that has this link can view your Calendar so make sure not to share it!

To allow automatic import to the Home Assistant calendar we need a plug-in. I use ics_calendar as it’s installable through HACS.

Now open your configuration.yaml, add the ICS link you copied earlier and restart your Home Assistant
calendar:
- platform: ics_calendar
calendars:
- name: "test"
url: "https://outlook.live.com/owa/calendar/xxxxx/xxxx/calendar.ics"
If everything was setup correctly your HA Calendar should start populating, so let’s create the automation which will draw the info for the next 7 days to the ePaper:
alias: Update ePaper Calendar
description: ""
trigger:
- platform: time_pattern
minutes: /15
condition: []
action:
- delay:
hours: 0
minutes: 4
seconds: 0
milliseconds: 0
enabled: true
- service: calendar.get_events
data:
start_date_time: >-
{{(now()).strftime('%Y-%m-%d') }} {{ now().hour }}:{{ now().minute }}:{{
now().second }}
end_date_time: "{{(now()).strftime('%Y-%m-%d') }} 23:59:59"
target:
entity_id: calendar.test
response_variable: cal_events1
alias: Get Appointments for today
- service: calendar.get_events
data:
start_date_time: "{{(now() - timedelta( days = -1)).strftime('%Y-%m-%d') }} 00:00:00"
end_date_time: "{{(now() - timedelta( days = -1)).strftime('%Y-%m-%d') }} 23:59:59"
target:
entity_id: calendar.test
response_variable: cal_events2
alias: Get Appointments for today +1
- service: calendar.get_events
data:
start_date_time: "{{(now() - timedelta( days = -2)).strftime('%Y-%m-%d') }} 00:00:00"
end_date_time: "{{(now() - timedelta( days = -2)).strftime('%Y-%m-%d') }} 23:59:59"
target:
entity_id: calendar.test
response_variable: cal_events3
alias: Get Appointments for today +2
- service: calendar.get_events
data:
start_date_time: "{{(now() - timedelta( days = -3)).strftime('%Y-%m-%d') }} 00:00:00"
end_date_time: "{{(now() - timedelta( days = -3)).strftime('%Y-%m-%d') }} 23:59:59"
target:
entity_id: calendar.test
response_variable: cal_events4
alias: Get Appointments for today +3
- service: calendar.get_events
data:
start_date_time: "{{(now() - timedelta( days = -4)).strftime('%Y-%m-%d') }} 00:00:00"
end_date_time: "{{(now() - timedelta( days = -4)).strftime('%Y-%m-%d') }} 23:59:59"
target:
entity_id: calendar.test
response_variable: cal_events5
alias: Get Appointments for today +4
- service: calendar.get_events
data:
start_date_time: "{{(now() - timedelta( days = -5)).strftime('%Y-%m-%d') }} 00:00:00"
end_date_time: "{{(now() - timedelta( days = -5)).strftime('%Y-%m-%d') }} 23:59:59"
target:
entity_id: calendar.test
response_variable: cal_events6
alias: Get Appointments for today +5
- service: calendar.get_events
data:
start_date_time: "{{(now() - timedelta( days = -6)).strftime('%Y-%m-%d') }} 00:00:00"
end_date_time: "{{(now() - timedelta( days = -6)).strftime('%Y-%m-%d') }} 23:59:59"
target:
entity_id: calendar.test
response_variable: cal_events7
alias: Get Appointments for today +6
- service: open_epaper_link.drawcustom
target:
entity_id:
- open_epaper_link.000002b338873418
data:
background: white
rotate: 180
payload:
- type: text
value: Agenda
font: rbm.ttf
x: 15
"y": 7
size: 30
color: red
- type: text
"y": 8
value: "{{(now()).strftime('%d-%m-%Y') }} "
font: ppb.ttf
x: 400
size: 14
anchor: rt
color: red
- type: text
"y": 36
value: "{{(now()).strftime('%H:%M') }}"
font: ppb.ttf
x: 399
size: 10
anchor: rb
color: red
- type: rectangle
x_start: 0
x_end: 400
y_start: 38
y_end: 39
fill: red
outline: red
width: 2
- type: multiline
value: >
{% if cal_events1['calendar.test'].events | count != 0 %}
{{(now()).strftime('%d-%m-%Y') }} |{% endif %}
{% for event in
cal_events1['calendar.test'].events|sort(attribute='start') -%} {%
if as_timestamp(event.start) | timestamp_custom('%H:%M') ==
"00:00"%} All day: {{ event.summary }}|{% else %} {{
(event.start|as_datetime).strftime('%H:%M') }}-{{
(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }}
|{% endif -%}{% endfor -%}
{% if cal_events2['calendar.test'].events | count != 0 %}{{(now() -
timedelta( days = -1)).strftime('%d-%m-%Y') }} |{% endif %}
{% for event in
cal_events2['calendar.test'].events|sort(attribute='start') -%} {%
if as_timestamp(event.start) | timestamp_custom('%H:%M') ==
"00:00"%} All day: {{ event.summary }}|{% else %} {{
(event.start|as_datetime).strftime('%H:%M') }}-{{
(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }}
|{% endif -%}{% endfor -%}
{% if cal_events3['calendar.test'].events | count != 0 %}{{(now() -
timedelta( days = -2)).strftime('%d-%m-%Y') }} |{% endif %}
{% for event in
cal_events3['calendar.test'].events|sort(attribute='start') -%} {%
if as_timestamp(event.start) | timestamp_custom('%H:%M') ==
"00:00"%} All day: {{ event.summary }}|{% else %} {{
(event.start|as_datetime).strftime('%H:%M') }}-{{
(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }}
|{% endif -%}{% endfor -%}
{% if cal_events4['calendar.test'].events | count != 0 %}{{(now() -
timedelta( days = -3)).strftime('%d-%m-%Y') }} |{% endif %}
{% for event in
cal_events4['calendar.test'].events|sort(attribute='start') -%} {%
if as_timestamp(event.start) | timestamp_custom('%H:%M') ==
"00:00"%} All day: {{ event.summary }}|{% else %} {{
(event.start|as_datetime).strftime('%H:%M') }}-{{
(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }}
|{% endif -%}{% endfor -%}
{% if cal_events5['calendar.test'].events | count != 0 %}{{(now() -
timedelta( days = -4)).strftime('%d-%m-%Y') }} |{% endif %}
{% for event in
cal_events5['calendar.test'].events|sort(attribute='start') -%} {%
if as_timestamp(event.start) | timestamp_custom('%H:%M') ==
"00:00"%} All day: {{ event.summary }}|{% else %} {{
(event.start|as_datetime).strftime('%H:%M') }}-{{
(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }}
|{% endif -%}{% endfor -%}
{% if cal_events6['calendar.test'].events | count != 0 %}{{(now() -
timedelta( days = -5)).strftime('%d-%m-%Y') }} |{% endif %}
{% for event in
cal_events6['calendar.test'].events|sort(attribute='start') -%} {%
if as_timestamp(event.start) | timestamp_custom('%H:%M') ==
"00:00"%} All day: {{ event.summary }}|{% else %} {{
(event.start|as_datetime).strftime('%H:%M') }}-{{
(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }}
|{% endif -%}{% endfor -%}
{% if cal_events7['calendar.test'].events | count != 0 %}{{(now() -
timedelta( days = -6)).strftime('%d-%m-%Y') }} |{% endif %}
{% for event in
cal_events7['calendar.test'].events|sort(attribute='start') -%} {%
if as_timestamp(event.start) | timestamp_custom('%H:%M') ==
"00:00"%} All day: {{ event.summary }}|{% else %} {{
(event.start|as_datetime).strftime('%H:%M') }}-{{
(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }}
|{% endif -%}{% endfor -%}
font: rbm.ttf
start_y: 50
offset_y: 20
delimiter: "|"
x: 10
size: 15
color: black
alias: Draw it to 4.2 ePaper
mode: single
Some explanation:
{{(now() - timedelta( days = -1)).strftime('%Y-%m-%d') }}
Expands to tomorrow since timedelta(days = -1) adds 24 hours (e.g. 2023-08-15)
{% if cal_events1.events | count != 0 %} {{(now()).strftime('%d-%m-%Y') }} #{% endif %}
If there are no actual events on a specific data I don’t want to show anything so we evaluate if the number of events is not 0. If it’s not we display the date, e.g. 15-08-2023
{% for event in cal_events1.events|sort(attribute='start') -%} {% if as_timestamp(event.start) | timestamp_custom('%H:%M') == "00:00"%} All day: {{ event.summary }}#{% else %} {{(event.start|as_datetime).strftime('%H:%M') }}-{{(event.end|as_datetime).strftime('%H:%M') }}: {{ event.summary }} #{% endif -%}{% endfor -%}
Now we enumerate all events in the Response Data. If it’s an all Day event (by checking for starttime of 00:00) we don’t show start/end time but in stead All Day:. If it’s not an all day event we show the start and end time.
More than one Office 365 Calendar
Since Home Assistant only allows enumerating one calendar using the list_events service and I’ve got 3 Office 365 calendars, a waste calendar and a favorite football team I want to see the upcoming events for we need to do some bash magic:
cd /var/www/cal
wget https://outlook.live.com/owa/calendar/xxxxx/cid-xxxx/calendar.ics -O 1.ics
wget https://outlook.live.com/owa/calendar/xxxxx/cid-xxxx/calendar.ics -O 2.ics
wget https://www.voetbalkrant.com/soccer/calendar/team/team_9_nl.ics -O 3.ics
wget http://192.168.1.24:8123/local/afval.ics -O 4.ics
wget https://outlook.live.com/owa/calendar/xxxxx/cid-xxxx/calendar.ics -O 5.ics
#Remove last line of 1.ics
sed -i '$d' 1.ics
#Remove first 20 lines of 2.ics
sed -i '1,20d' 2.ics
#Remove last line of 2.ics
sed -i '$d' 2.ics
#Remove first 7 lines of 3.ics
sed -i '1,7d' 3.ics
#Remove last line of 3.ics
sed -i '$d' 3.ics
#Remove first 20 lines of 5.ics
sed -i '1,20d' 5.ics
#Remove last line of 5.ics
sed -i '$d' 5.ics
#Remove first 8 lines of 4.ics
sed -i '1,8d' 4.ics
cat 1.ics 2.ics 3.ics 5.ics 4.ics > temp.txt
# now strip out the overlapping lines and make one big valid ICS file
cat temp.txt | grep -v DARBEG > comb.ics
# Fix Microsofts weird timezones:
sed -i 's/"tzone:\/\/Microsoft\/Custom"/Europe\/Amsterdam/g' comb.ics
sed -i 's/Customized Time Zone/Europe\/Amsterdam/g' comb.ics
sed -i 's/W. Europe Standard Time/Europe\/Amsterdam/g' comb.ics
We need to remove all superfluous headers and footers (20 lines for Office365 ICS files, 7 for the football feed and 8 for the Waste calendar) and since Microsoft uses invalid TZone variables we also need to fix those. sed is the obvious choice for that so I used that. I create a simple apache2 website to host the comb.ics file and changed the calendar test to point to that ics file
calendar:
- platform: ics_calendar
calendars:
- name: "test"
url: "http://192.168.1.24/comb.ics"

Works like a charm 🙂