My mum had dementia, and I bought a simple clock for her to make it easier to know what day it was etc. I would note that many clocks you can buy that are designed for this, are digital - when some older people like my mum are more comfortable with analogue clocks.
On the list of things for me to do, was to make a more useful clock - one that I could remotely put information on such as who was going to be visiting her and when. The thought was that it would normally show the time, but maybe every few minutes show other information. I thought it could even be used in a care environment, with information updated via the likes of MQTT.
Sadly, she passed away before I could complete it - but I thought that there may be someone out there who may benefit from what I’ve put together. I’ve used a 3D printed case to house the ‘proof of concept’, although there are other options out there such as wooden photo frames that could be used instead.
So, here are some photos as well as the sample code - I’ve left a few comments in the code to help.
The classic ‘simple’ display:
The more detailed display:
A close up of the display, showing the power icon bottom right when it is plugged in to charge
A example of a patient info board:
…and finally, the back of the screen. I am not proud of this, so best not to see what’s behind the curtains!
Yes, all of that should be put into a protective case!
So anyhow, here is the code:
# Compiled and tested on esphome 2025.6.0 and HA 2025.7.0
# Note
# - Using Lolin D32 as has built in battery support - see here for info: https://www.wemos.cc/en/latest/d32/d32.html
# - To reset, may need to jumper pin 0 to ground for 10 seconds
# - Can also erase esp with https://espressif.github.io/esptool-js/
# The D32 has a blue status light - code turns that off by default to save eyes and power
# The D32 has a red light that glows when plugged into power - can't disable that. Could possibly crush, but I'd leave it alone.
# I'm using a 2500mah battery - appears to support the screen for well over a day
# For the case, am using https://www.thingiverse.com/thing:4807262
# The gubbins are hanging out the back exposed, so for a more permanent display I'd hide/protect it in a box
# Have coded in a simple press button to toggle between the screens, as well as a virtual slider in esphome to choose screens
# The preferred screen choice is saved between boots - if you don't need to do this, probably would remove it to avoid performing too many writes to memory
# I also run a cable between USB and GPIO16 - this detects when power is coming in via the USB port
#
# The following code assumes you are using the newer e-hat with 9 cables, one marked VCC (which can be either 3 or 5V) and
# another more mysterious one marked as PWR. The older e-hats did not have this.
# Connect the PWR cable to a random GPIO pin. Basically this pin is used to toggle the screen power on or off. Normally
# a pin is HIGH ie has 3V running to it, so the screen will work. If you want you could have a switch that toggles
# the pin on or off to in turn switch the screen on or off. Handy for power saving possibly.
# The screen may or may not work if you don't plug in the PWR cable, but I'd plug it in t make sure
#
# I have created three demo screens
# - one that is quite basic, that could be used in a situation where a person needs a simple display, for example the elderly, dementia, etc
# - one that builds on the first, adding more info such as an analogue clock
# - one that displays just some information. In this case I've put in an example for a hospital display showing patient info
# that potentially could be populated automatically via the likes of MQTT
substitutions:
name: eclock01
friendly_name: eclock01
devicename: eclock01
location: master
# The following is optional - put in if you want to run off a battery and save power
# run_time: 60s #can be as long as needed to get data
# sleep_time: 4min # normal sleep time
# night_sleep_time: 10h # 1st sleep time after midnight
# See also #deep_sleep: and #script:
esphome:
name: ${name}
friendly_name: ${friendly_name}
min_version: 2025.5.0
name_add_mac_suffix: false
project:
name: ninkasi.clk
version: '1.1'
comment: Eclock LOLIN D32 $location
platformio_options:
build_flags:
- "-D CONFIG_ADC_SUPPRESS_DEPRECATE_WARN=1" # This just stops ADC warning messages from appearing when compiling
esp32:
board: esp32dev
framework:
type: esp-idf
# Enable logging
logger:
baud_rate: 0
logs:
component: ERROR
api:
encryption:
key: !secret esphome_encryption_key
ota:
password: !secret ota_password
platform: esphome
wifi:
networks:
- ssid: !secret wifIoT_ssid
password: !secret wifIoT_password
priority: 2
- ssid: !secret wifi_ssid
password: !secret wifi_password
priority: 1
ap:
ssid: "$devicename Fallback Hotspot"
password: !secret ota_password
# This is optional for power saving
#deep_sleep:
# run_duration: ${run_time}
# sleep_duration: ${sleep_time}
# id: deep_sleep_1
#script:
# - id: all_data_received
# then:
# - component.update: battery_voltage
# - component.update: epaper_display
# - script.execute: enter_sleep
# - id: enter_sleep
# then:
# - if:
# condition:
# lambda: |-
# auto time = id(sntp_time).now();
# if (!time.is_valid()) {
# return false;
# }
# return (time.hour > 21);
# then:
# - logger.log: "It's nighttime, entering long sleep for ${night_sleep_time}"
# - deep_sleep.enter:
# id: deep_sleep_1
# sleep_duration: ${night_sleep_time}
# else:
# - logger.log: "It's daytime, entering short sleep for ${sleep_time}"
# - deep_sleep.enter:
# id: deep_sleep_1
# sleep_duration: ${sleep_time}
time:
- platform: homeassistant
id: sntp_time
- platform: sntp
on_time:
- seconds: 0
minutes: 0
hours: 2
then:
- delay: 10s
- button.press: restart_esphome
button:
- platform: restart
name: "$devicename Restart"
id: restart_esphome
icon: "mdi:restart"
number:
- platform: template
name: "$devicename Display Screen"
id: display_screen_selector
optimistic: true
min_value: 1
max_value: 3
step: 1
restore_value: yes
switch:
- platform: shutdown
name: "$devicename Shutdown"
- platform: gpio
id: blue_led
pin:
number: GPIO5 # The Lolin D32 uses GPIO5 for the status LED, for reasons.
mode: OUTPUT
inverted: true
ignore_strapping_warning: true #GPIO5 is a strapping pin, so will otherwise get warning messages when compiling
name: "Blue LED"
restore_mode: ALWAYS_OFF
sensor:
- platform: wifi_signal
name: "WiFi Signal Sensor"
id: wifisignal
update_interval: 60s
unit_of_measurement: dBm
accuracy_decimals: 0
device_class: signal_strength
state_class: measurement
entity_category: diagnostic
- platform: copy
source_id: wifisignal
id: wifipercent
name: "WiFi Signal Percent"
filters:
- lambda: return min(max(2 * (x + 100.0), 0.0), 100.0);
unit_of_measurement: "Signal %"
entity_category: "diagnostic"
- platform: uptime
id: uptime_s
name: "$devicename Uptime"
update_interval: 60s
- platform: template
name: $devicename free memory
lambda: return heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
icon: "mdi:memory"
entity_category: diagnostic
state_class: measurement
unit_of_measurement: "b"
update_interval: 60s
- platform: adc
pin: GPIO35
name: "Battery Capacity"
id: battery_capacity
icon: mdi:battery-medium
unit_of_measurement: "%"
accuracy_decimals: 0
attenuation: 12db
update_interval: 60s
filters:
- multiply: 2.0
- median:
window_size: 7
send_every: 7
send_first_at: 7
- throttle: 15min
- calibrate_polynomial:
degree: 3
datapoints:
# Note that these datapoints will need to be calibrated to suit the specific battery you use
# Without calibration, the screen may - for example - die when it is showing there is power left
- 0.00 -> 0.0
- 3.30 -> 0.0
- 3.35 -> 5.0
- 3.39 -> 10.0
- 3.44 -> 15.0
- 3.48 -> 20.0
- 3.53 -> 25.0
- 3.57 -> 30.0
- 3.62 -> 35.0
- 3.66 -> 40.0
- 3.71 -> 45.0
- 3.75 -> 50.0
- 3.80 -> 55.0
- 3.84 -> 60.0
- 3.88 -> 65.0
- 3.92 -> 70.0
- 3.96 -> 75.0
- 4.00 -> 80.0
- 4.05 -> 85.0
- 4.09 -> 90.0
- 4.14 -> 95.0
- 4.20 -> 100.0
- lambda: |-
if (x < 96) {
return x;
} else {
return 100;
}
- platform: homeassistant
id: out_temp
entity_id: sensor.gw1000_v1_7_6_outdoor_temperature
binary_sensor:
- platform: gpio
id: button_1
name: "Button1"
pin:
number: GPIO19
mode:
input: true
pullup: true
inverted: true
on_press:
- logger.log: "button was pressed!"
- number.set:
id: display_screen_selector
value: !lambda |-
int current_screen = id(display_screen_selector).state;
if (current_screen >= 3) {
return 1;
} else {
return current_screen + 1;
}
- component.update: eink_display
- platform: template
name: "Low Battery"
lambda: |-
if (id(battery_capacity).state < 30) {
return true;
} else {
return false;
}
- platform: gpio
id: usb_power
pin: 16
name: "USB Power Status"
device_class: power
filters:
- delayed_on: 100ms
- delayed_off: 100ms
font:
- file: "gfonts://Roboto"
id: sans_serif_9
size: 9
- file:
type: gfonts
family: Roboto
weight: 700
id: sans_serif_bold_9
size: 20
- file:
type: gfonts
family: Roboto
weight: 400
id: info_font
size: 30
- file:
type: gfonts
family: Roboto
weight: 700
id: header_info_font
size: 30
- file:
type: gfonts
family: Roboto
weight: 700
id: large_font
size: 70
- file:
type: gfonts
family: Roboto
weight: 700
id: medium_font
size: 40
- file:
type: gfonts
family: Roboto
weight: 700
id: large_medium_font
size: 60
- file:
type: gfonts
family: Roboto
weight: 400
id: large_medium_regular_font
size: 60
- file:
type: gfonts
family: Roboto
weight: 900
id: xxl_font
size: 140
- file:
type: gfonts
family: Roboto
weight: 900
id: jumbo_font
size: 180
- file:
type: gfonts
family: Roboto
weight: 900
id: ampm_font
size: 90
# Note - need to install this locally. Is just used for the power icon so if you don't care about that then can leave it out
- file: fonts/materialdesignicons-webfont.ttf
id: mdi_font
size: 30
glyphs:
- "\U000F06A5" # mdi-power-plug
spi:
clk_pin: GPIO4
mosi_pin: GPIO18
display:
- platform: waveshare_epaper
id: eink_display
cs_pin: GPIO22
dc_pin: GPIO23
busy_pin:
number: GPIO32
inverted: true
reset_pin: GPIO21
reset_duration: 2ms
model: 7.50inV2p # Note - this is for the 7.5inch Waveshare e-paper screen with a V2 sticker on the back. It provides partial screen refresh!
update_interval: 60s
full_update_every: 60
rotation: 0°
lambda: |-
const int W = it.get_width();
const int H = it.get_height();
Color fg_color = COLOR_ON;
Color bg_color = COLOR_OFF;
it.fill(bg_color);
// --- Helper Functions ---
auto polar2cart = [&](float x, float y, float r, float alpha, int& cx, int& cy) {
alpha = alpha * M_PI / 180.0;
cx = int(x + r * sin(alpha));
cy = int(y - r * cos(alpha));
};
auto draw_triangle = [&](float x, float y, float r, float alpha, int width, int len) {
int x0, y0, x1, y1, x2, y2;
polar2cart(x, y, len, alpha, x2, y2);
polar2cart(x, y, width, alpha - 90, x1, y1);
polar2cart(x, y, width, alpha + 90, x0, y0);
it.filled_triangle(x0, y0, x1, y1, x2, y2, fg_color);
};
// --- Persistent Drawing Function (for all screens) ---
auto draw_battery_status = [&]() {
if (id(battery_capacity).has_state()) {
float level = id(battery_capacity).state;
int icon_w = 40;
int icon_h = 20;
int term_w = 4;
int term_h = 10;
int margin = 15;
int text_gap = 18;
int icon_x = W - icon_w - term_w - margin;
int icon_y = H - icon_h - margin - text_gap;
if (id(usb_power).has_state() && id(usb_power).state) {
it.printf(icon_x + icon_w / 2, icon_y - 5, id(mdi_font), fg_color, TextAlign::BOTTOM_CENTER, "\U000F06A5");
}
it.rectangle(icon_x, icon_y, icon_w, icon_h, fg_color);
it.filled_rectangle(icon_x + icon_w, icon_y + (icon_h - term_h) / 2, term_w, term_h, fg_color);
if (level > 5) {
int inner_margin = 3;
int charge_w_max = icon_w - (inner_margin * 2);
int charge_w = charge_w_max * (level / 100.0);
int charge_h = icon_h - (inner_margin * 2);
it.filled_rectangle(icon_x + inner_margin, icon_y + inner_margin, charge_w, charge_h, fg_color);
}
it.printf(icon_x + (icon_w / 2), icon_y + icon_h + 4, id(sans_serif_bold_9), fg_color, TextAlign::TOP_CENTER, "%.0f%%", level);
}
};
// --- Main Screen Selection ---
int screen = (int)id(display_screen_selector).state;
if (screen == 2) {
// --- Screen 2 (Detailed Display) ---
const int CW = W / 1.5;
const int CH = H / 2;
const int R = min(W, H) / 2 - 10;
auto time_to_words = [&](int h, int m) -> std::string {
int rounded_m = round(m / 5.0) * 5;
if (rounded_m == 60) { rounded_m = 0; h = (h + 1) % 24; }
const char* hours_in_words[] = {"twelve", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven"};
std::string hour_word = hours_in_words[h % 12];
std::string next_hour_word = hours_in_words[(h + 1) % 12];
switch (rounded_m) {
case 0: return "It's " + hour_word + " o'clock";
case 5: return "It's five past " + hour_word;
case 10: return "It's ten past " + hour_word;
case 15: return "It's quarter past " + hour_word;
case 20: return "It's twenty past " + hour_word;
case 25: return "It's twenty-five past " + hour_word;
case 30: return "It's half past " + hour_word;
case 35: return "It's twenty-five to " + next_hour_word;
case 40: return "It's twenty to " + next_hour_word;
case 45: return "It's quarter to " + next_hour_word;
case 50: return "It's ten to " + next_hour_word;
case 55: return "It's five to " + next_hour_word;
}
return "";
};
auto draw_top_left_info = [&]() {
auto now = id(sntp_time).now();
it.printf(15, 15, id(large_font), fg_color, TextAlign::TOP_LEFT, "%s", now.strftime("%A").c_str());
char date_buffer[20];
sprintf(date_buffer, "%d %s", now.day_of_month, now.strftime("%B").c_str());
it.printf(15, 80, id(large_font), fg_color, TextAlign::TOP_LEFT, "%s", date_buffer);
std::string time_str = time_to_words(now.hour, now.minute);
it.printf(15, 150, id(medium_font), fg_color, TextAlign::TOP_LEFT, "%s", time_str.c_str());
std::string status_str;
if (now.hour >= 5 && now.hour < 12) status_str = "in the morning";
else if (now.hour >= 12 && now.hour < 17) status_str = "in the afternoon";
else if (now.hour >= 17 && now.hour < 21) status_str = "in the evening";
else status_str = "at night";
it.printf(15, 200, id(medium_font), fg_color, TextAlign::TOP_LEFT, "%s", status_str.c_str());
};
auto draw_clock_face = [&]() {
int cx, cy;
it.circle(CW, CH, R, fg_color);
it.filled_circle(CW, CH, 8, fg_color);
for (int h = 1; h <= 12; h++) {
float alpha = 360.0 * h / 12;
polar2cart(CW, CH, R - 25, alpha, cx, cy);
it.printf(cx, cy, id(sans_serif_bold_9), fg_color, TextAlign::CENTER, "%d", h);
}
};
auto draw_clock_hands = [&]() {
auto now = id(sntp_time).now();
float minute_angle = now.minute * 6.0 + now.second * 0.1;
float hour_angle = (now.hour % 12) * 30.0 + now.minute * 0.5;
draw_triangle(CW, CH, R, minute_angle, 6, R - 50);
draw_triangle(CW, CH, R, hour_angle, 12, R - 80);
it.filled_circle(CW, CH, 8, fg_color);
};
auto draw_outdoor_temp = [&]() {
if (id(out_temp).has_state()) {
it.printf(15, H - 15, id(large_font), fg_color, TextAlign::BOTTOM_LEFT, "%.1f\u00B0C", id(out_temp).state);
}
};
auto draw_digital_clock = [&]() {
auto now = id(sntp_time).now();
if (now.is_valid()) {
it.printf(W - 15, 15, id(medium_font), fg_color, TextAlign::TOP_RIGHT, "%s", now.strftime("%H:%M").c_str());
}
};
draw_top_left_info();
draw_clock_face();
draw_clock_hands();
draw_outdoor_temp();
draw_digital_clock();
} else if (screen == 3) {
// --- Screen 3 (Hospital Info Display) ---
int margin = 20;
int top_header_height = 80;
int line_spacing = 70;
int current_x, x1, y1, w1, h1;
it.filled_rectangle(0, 0, W, top_header_height, fg_color);
it.printf(margin, top_header_height / 2, id(large_medium_font), bg_color, TextAlign::CENTER_LEFT, "Ward: 3 Room: 12 Bed: 2");
const char* patient_label = "Patient: "; const char* patient_name = "George Smith";
int y_pos = top_header_height + 15;
it.printf(margin, y_pos, id(large_medium_regular_font), fg_color, TextAlign::TOP_LEFT, patient_label);
it.get_text_bounds(margin, y_pos, patient_label, id(large_medium_regular_font), TextAlign::TOP_LEFT, &x1, &y1, &w1, &h1);
it.printf(x1 + w1, y_pos, id(large_medium_font), fg_color, TextAlign::TOP_LEFT, patient_name);
const char* physician_label = "Physician: "; const char* physician_name = " Dr Jane Wood";
y_pos += line_spacing;
it.printf(margin, y_pos, id(large_medium_regular_font), fg_color, TextAlign::TOP_LEFT, physician_label);
it.get_text_bounds(margin, y_pos, physician_label, id(large_medium_regular_font), TextAlign::TOP_LEFT, &x1, &y1, &w1, &h1);
it.printf(x1 + w1, y_pos, id(large_medium_font), fg_color, TextAlign::TOP_LEFT, physician_name);
int bottom_section_y = 240;
it.line(0, bottom_section_y, W, bottom_section_y, fg_color);
int col_width = W / 3;
int box_y_start = bottom_section_y;
int header_height = 40;
it.line(col_width, box_y_start, col_width, H, fg_color);
it.line(col_width * 2, box_y_start, col_width * 2, H, fg_color);
it.filled_rectangle(0, box_y_start, W, header_height, fg_color);
it.printf(col_width / 2, box_y_start + (header_height / 2), id(header_info_font), bg_color, TextAlign::CENTER, "Allergies");
it.printf(W / 2, box_y_start + (header_height / 2), id(header_info_font), bg_color, TextAlign::CENTER, "Diet");
it.printf(W - (col_width / 2), box_y_start + (header_height / 2), id(header_info_font), bg_color, TextAlign::CENTER, "Fall Risk");
int content_y_start = box_y_start + header_height + 20;
it.printf(col_width / 2, content_y_start, id(info_font), fg_color, TextAlign::TOP_CENTER, "Latex");
it.printf(col_width / 2, content_y_start + 40, id(info_font), fg_color, TextAlign::TOP_CENTER, "Penicillin");
it.printf(W / 2, content_y_start, id(info_font), fg_color, TextAlign::TOP_CENTER, "Regular");
it.printf(W - (col_width / 2), content_y_start, id(info_font), fg_color, TextAlign::TOP_CENTER, "High");
} else {
// --- Screen 1 (Minimalist Display - Default) ---
auto now = id(sntp_time).now();
it.printf(W/2, 15, id(xxl_font), fg_color, TextAlign::TOP_CENTER, "%s", now.strftime("%A").c_str());
std::string status_str;
if (now.hour >= 5 && now.hour < 12) status_str = "Morning";
else if (now.hour >= 12 && now.hour < 17) status_str = "Afternoon";
else if (now.hour >= 17 && now.hour < 21) status_str = "Evening";
else status_str = "Night";
it.printf(W/2, 170, id(large_medium_font), fg_color, TextAlign::TOP_CENTER, "%s", status_str.c_str());
if (now.is_valid()) {
char time_buffer[8];
char ampm_buffer[4];
int hour12 = now.hour % 12;
if (hour12 == 0) { hour12 = 12; }
sprintf(time_buffer, "%d:%02d", hour12, now.minute);
sprintf(ampm_buffer, "%s", (now.hour < 12) ? "AM" : "PM");
int time_x, time_y, time_w, time_h;
it.get_text_bounds(0, 0, time_buffer, id(jumbo_font), TextAlign::TOP_LEFT, &time_x, &time_y, &time_w, &time_h);
int ampm_x, ampm_y, ampm_w, ampm_h;
it.get_text_bounds(0, 0, ampm_buffer, id(ampm_font), TextAlign::TOP_LEFT, &m_x, &m_y, &m_w, &m_h);
int gap = 15;
int total_width = time_w + ampm_w + gap;
int start_x = (W - total_width) / 2;
int status_bottom = 170 + id(large_medium_font).get_height();
int date_top = H - 15 - id(medium_font).get_height();
int midpoint = status_bottom +60 + (date_top - status_bottom) / 2;
int draw_y = midpoint;
it.printf(start_x, draw_y, id(jumbo_font), fg_color, TextAlign::BASELINE_LEFT, time_buffer);
it.printf(start_x + time_w + gap, draw_y, id(ampm_font), fg_color, TextAlign::BASELINE_LEFT, ampm_buffer);
}
char date_buffer[25];
sprintf(date_buffer, "%d %s %d", now.day_of_month, now.strftime("%B").c_str(), now.year);
it.printf(W/2, H - 15, id(medium_font), fg_color, TextAlign::BOTTOM_CENTER, "%s", date_buffer);
}
// --- Draw Persistent Elements ---
draw_battery_status();
Hardware used:
- Lolin D32 - D32 — WEMOS documentation
- Waveshare 7.5 inch epaper with hat (yes I know it says raspberry pi, but it works with an esp32 just fine) - 800×480, 7.5inch E-Ink display HAT for Raspberry Pi, SPI interface | WF0583CZ09
- A momentary button to toggle through screens - rescued from some packaging and optional
- A battery - optional
- Some dupont cables or could solder it
- A 3D printed case - I used this but is optional (a quick search on google will reveal a number of people who have repurposed frames from Ikea to house 7.5 inch epaper screens)




