Dementia/Alzheimer epaper clock

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! :wink:

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, &ampm_x, &ampm_y, &ampm_w, &ampm_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:

20 Likes