ESPHome config to read from PC-60FW bluetooth Pulse Oximeter and display locally

The following config.yaml interfaces to a bluetooth wireless PC-60FW Pulse Oximeter (I bought mine for about $16 on Aliexpress)

The config:

  1. Works with a standard ESP32-devkit device
  2. Creates 2 new sensor entities:
    • sensor.pc_60fw_o2sat
    • sensor.pc_60fw_pulse
  3. Displays the O2Sat & Pulse (plus total connected time and current connected time) on a 2-line i2C 1602 display
  4. The sampling and display update time is set to 1-second but it can be changed via the substitution variable update_interval
  5. Short press on the boot button toggles recording to HA
    (but still updates the display)
  6. Long press on the boot button resets connection times

The script connects & disconnects automatically from the pulse ox.

Note that the display is optional. If you have no display attached, it will still log O2sat & heart rate and transfer it to Home Assistant.

Note: I tried using a higher-resolution TENSTAR T-display ESP32 but it lacked sufficient SRAM to power the WiFi, BT, and Display simultaneously

Enjoy:

UPDATED: 2-25-05-06

###############################################################################
#### PulseOx-PC60FW-ble-1602
# Jeff Kosowsky
# Version 0.9.10
# May 2025
# Copyright 2025
###############################################################################
# DESCRIPTION:
#   Read from bluetooth-enabled PC60FW Pulse Oximeter and display on a 2-line
#   16-character width display
#
## NOTES:
# - HA Sensors created:
#     SpO2: sensor.pc_60fw_spo2
#     Pulse: sensor.pc_60fw_pulse
#     Perfusion Index: sensor.pc_60fw_perf_index
#
# - Display layout:
#     Top Row: Total sensor read time and current read time
#              separated by a once per-second blinking character:
#                + if set to record and HA connected and recording
#                # if set to record and HA *not* connected
#                - if not set to record to HA
#     Bottom Row: SpO2 and Pulse
#         Note that current values displayed regardless of whether HA connected and/or recording.
#         If PC-60FW not connected, then last valid values displayed
#
# - Boot Button:
#     Short press  to toggle recording to HA on/off 
#     Long press to reset connected times and data values to zero
#
## NOTE: you also need to set the following variables in `secrets.yaml`:
#  wifi_ssid, wifi_password, web_server_username, web_server_password
#
###############################################################################
#### Non-Display-specific code

substitutions:
  name: pc-60fw
  friendly_name: PC-60FW PulseOx
  update_interval: 1s  # Update frequency for sensors and display
  #Following determine range of SpO2 and Pulse ranges [USED ONLY for graphical displays]
  sat_lo: "95"
  sat_vlo: "90"
  pulse_vhi: "100"
  pulse_hi: "75"
  pulse_lo: "55"
  pulse_vlo: "40"
  perf_index_lo: "20"
  perf_index_med: "60"

globals:
  - id: connect_start
    type: int
    restore_value: no
    initial_value: '-1'
  - id: connect_length
    type: int
    restore_value: no
    initial_value: '0'
  - id: connect_total
    type: int
    restore_value: no
    initial_value: '0'
  - id: connect_oldtotal
    type: int
    restore_value: no
    initial_value: '0'
  - id: ha_connected
    type: bool
    restore_value: no
    initial_value: 'false' #Default to disconnected
  - id: record_to_ha
    type: bool
    restore_value: yes #Save state across reboots
    initial_value: 'true'
  - id: spo2_current
    type: uint8_t
    restore_value: no
    initial_value: '0'
  - id: pulse_current
    type: uint8_t
    restore_value: no
    initial_value: '0'
  - id: perf_index_current
    type: uint8_t
    restore_value: no
    initial_value: '255'
  - id: battery_current
    type: uint8_t
    restore_value: no
    initial_value: '255'

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2024.6.0
  name_add_mac_suffix: false
  project:
    name: esphome.web
    version: dev
  platformio_options:
    upload_speed: 921600 #Default: 115200
    build_flags:
      - -DCONFIG_ARDUINO_LOOP_STACK_SIZE=8192
      - -DCONFIG_BT_BLE_50_FEATURES_SUPPORTED=1  # Reduce BLE features and thus size
          
esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:
  level: WARN #Default: DEBUG

# Enable Home Assistant API
api:
  on_client_connected:
    then:
      - lambda: |-
          id(ha_connected) = true;
          ESP_LOGW("ha", "HA Connected");
  on_client_disconnected:
    then:
      - lambda: |-
          id(ha_connected) = false;
          ESP_LOGW("ha", "HA Disconnected");

# Allow Over-The-Air updates
ota:
- platform: esphome

# Allow provisioning Wi-Fi via serial
improv_serial:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true #Reduce initialization time and memory usage since not scanning
  power_save_mode: LIGHT #Reduce memory/CPU usage at cost of slight delay
  
  # Set up a fallback wifi access point to configure WiFi if can't connect
  ap:
    ssid: "PC-60FW"

# In combination with the `ap` this allows the user
# to provision wifi credentials to the device via WiFi AP.
captive_portal:

#dashboard_import: #Not necessary
#  package_import_url: github://esphome/example-configs/esphome-web/esp32.yaml@main
#  import_full_config: true

# Sets up Bluetooth LE (Only on ESP32) to allow the user
# to provision wifi credentials to the device.
#esp32_improv:
#  authorizer: none

# To have a "next url" for improv serial
web_server:
  auth:
    username: !secret web_server_username
    password: !secret web_server_password

esp32_ble_tracker:
  scan_parameters:
    interval: 3000ms  # Increased from default (typically 320ms)
    window: 160ms     # Kept the same
    active: false     # Disabled

ble_client:
  - mac_address: 00:00:00:03:10:C4
    id: pc_60fw
    on_connect:
      then:
        - logger.log: 
            level: WARN
            format: "***PC-60FW connected***"
        - lambda: |-
            id(connect_start) = (int) (millis() / 1000);  // store uptime in seconds
            id(connect_oldtotal) += id(connect_length);
    on_disconnect:
      then:
        - logger.log: 
            level: WARN
            format: "***PC-60FW disconnected***"

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO0 #Boot button
      mode: INPUT_PULLUP
      inverted: true
    id: boot_button
    internal: true
    on_multi_click:
      # Short press: Toggle recording switch
      - timing:
          - ON for at most 1s
          - OFF for at least 0.1s
        then:
          - lambda: |-
              id(record_to_ha) = !id(record_to_ha);
              ESP_LOGW("boot_button", "HA Recording: %s", id(record_to_ha) ? "ON" : "OFF");
      # Long press: Reset connection times
      - timing:
          - ON for at least 2s
        then:
          - lambda: |-
              id(connect_length) = 0;
              id(connect_oldtotal) = 0;
              id(connect_start) = (int) (millis() / 1000);
              id(spo2_current) = id(pulse_current) = 0;
              id(perf_index_current) = id(battery_current) = 255;
          - logger.log:
              level: WARN
              format: "Reset connection times"  

sensor:
  # Bluetooth PC-60FW SpO2 & Pulse sensor
  - platform: ble_client
    type: characteristic
    id: internal_pulseox
    internal: true #Don't create HA entity
    ble_client_id: pc_60fw
    service_uuid: '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
    characteristic_uuid: '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
    notify: true  # Enable notifications for the characteristic
    lambda: |-
      if(x.size() == 12 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0x0F && x[3] == 0x08) {
        // Check if the data is at least 11 bytes and starts with AA 55 0F 08 01
        // Bytes 1-5: AA-55-0F-08-01 (where the 4th byte is the number of bytes following it, e.g., 8)
        // 6th byte (index 5) O2 saturation
        // 7th byte (index 6) Pulse
        // 9th byte (index 8) Perfusion index (?)
        // See: https://github.com/sza2/viatom_pc60fw
        ESP_LOGW("pulseox", "SpO2: %d\tPulse %d\tPI: %d", x[5], x[6], x[8]);
        if(x[5] != 0) { //SpO2
          id(spo2_current) = x[5];
          if(id(record_to_ha)) id(spo2).publish_state(id(spo2_current));
        }
        if(x[6] != 0) { 
          id(pulse_current) = x[6]; //Pulse
          if(id(record_to_ha)) id(pulse).publish_state(id(pulse_current));

          id(perf_index_current) = x[8]; //Pulse Index
          if(id(record_to_ha)) id(perfindex).publish_state(id(perf_index_current));
        }
      }else if (x.size() == 7 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0xF0 && x[3] == 0x3 && x[4] == 0x3) {
        // 6th byte (index 5) Battery level 0-3
        id(battery_current) = x[5];

        unsigned long uptime = millis()/1000;
        ESP_LOGW("pulsox", "Uptime: %02d:%02d:%02d Total Read: %02d:%02d:%02d  Current Read: %02d:%02d:%02d  Battery: %d",
          uptime / 3600, (uptime % 3600) / 60, uptime % 60,
          id(connect_total) / 3600, (id(connect_total) % 3600) / 60, id(connect_total) % 60,
          id(connect_length) / 3600, (id(connect_length) % 3600) / 60, id(connect_length) % 60,
          id(battery_current));
      }
      return {}; // This sensor doesn't report its own state

  - platform: template
    id: spo2
    name: "SpO2"
    icon: 'mdi:lung'
    unit_of_measurement: '%'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: pulse
    name: "Pulse"
    icon: 'mdi:heart-pulse'
    unit_of_measurement: 'bpm'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: perfindex
    name: "Perf Index"
    icon: 'mdi:waves'
    unit_of_measurement: 'PI'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

###############################################################################
##### Code for 1602 2-line, 16-char display follows
i2c:
  sda: 21
  scl: 22
  scan: false #Not needed since we set the address manually under 'display:' (set to 'true' for debugging)
  frequency: 400kHz

display:
  - platform: lcd_pcf8574
    id: lcd_display
    address: 0x27  # Adjust if needed (e.g., 0x3F)
    dimensions: 16x2
    update_interval: ${update_interval}
    lambda: |-
      if (id(pc_60fw).connected() && id(connect_start) != -1) {
        id(connect_length) = (millis() / 1000) - id(connect_start);
      }
      int connect_total = id(connect_oldtotal) + id(connect_length);
      static bool blink = true;
      blink = not(blink); //Toggle every other display to show alive
      char separator_char = ' ';
      if(blink) {
        if(id(record_to_ha)) {
          separator_char = id(ha_connected)? '+' : '#'; //'#' means set to record to HA but not connected
        }
        else {
          separator_char = '-';
        }
      }
      it.printf(0, 0, "%2d:%02d:%02d%c%d:%02d:%02d",
        (connect_total / 3600)%100, (connect_total % 3600) / 60, connect_total % 60,
        separator_char,
        (id(connect_length) / 3600)%10, (id(connect_length) % 3600) / 60, id(connect_length) % 60);
      if(id(pulse_current) != 0)
        it.printf(0, 1, "O2:%3d%% P:%3d B%1d", id(spo2_current), id(pulse_current), id(battery_current));
      else
        it.print(0, 1, "O2: --% P: -- B-");
      ESP_LOGI("mem", "Heap after final print: %u", ESP.getFreeHeap());
1 Like

Here is a version that runs on a graphical 240x135 OLED tdisplay with beautiful graphics:

Enjoy!

UPDATED: 2025-05-06

 ##### PulseOx-PC60FW-ble
# Jeff Kosowsky
# Version 0.9.10
# May 2025
# Copyright 2025
###############################################################################
# DESCRIPTION:
#   Read from bluetooth-enabled PC60FW Pulse Oximeter and display on a 240x135
#   pixel OLED tdisplay
#
## NOTES:
# - HA Sensors created:
#     SpO2: sensor.pc_60fw_spo2
#     Pulse: sensor.pc_60fw_pulse
#     Perfusion Index: sensor.pc_60fw_perf_index
#
# - Display layout:
#     Top Row: Total sensor read time and current read time
#              plus 4-state battery level icon (green if full, red empty, yellow otherwise)
#     Middle Row: Large SpO2 and Pulse reading plus smaller Perfusion Index reading
#                 (green/yellow/red based on ranges set below)
#         Note that current values displayed regardless of whether HA connected and/or recording
#         If PC-60FW not connected, then last valid values displayed
#     Bottom Row: HA ALIVE/HA DROP
#                 NO RECORD/RECORD/RECORDING (red if recording)
#                 Pulsing heart (every second): Red if PC-60FW connected, blue otherwise
#
# - Boot Button:
#     Short press  to toggle recording to HA on/off 
#     Long press to reset connected times and data values to zero
#
## NOTE: you also need to set the following variables in `secrets.yaml`:
#  wifi_ssid, wifi_password, web_server_username, web_server_password
#  NOTE: Currently commented-out excluded to conserve precious SRAM
#
###############################################################################
#### Non-Display-specific code

substitutions:
  name: pc-60fw
  friendly_name: PC-60FW PulseOx
  update_interval: 1s  # Update frequency for sensors and display
  #Following determine range of SpO2 and Pulse ranges [USED ONLY for graphical displays]
  sat_lo: "95"
  sat_vlo: "90"
  pulse_vhi: "100"
  pulse_hi: "75"
  pulse_lo: "55"
  pulse_vlo: "40"
  perf_index_lo: "20"
  perf_index_med: "60"

globals:
  - id: connect_start
    type: int
    restore_value: no
    initial_value: '-1'
  - id: connect_length
    type: int
    restore_value: no
    initial_value: '0'
  - id: connect_total
    type: int
    restore_value: no
    initial_value: '0'
  - id: connect_oldtotal
    type: int
    restore_value: no
    initial_value: '0'
  - id: ha_connected
    type: bool
    restore_value: no
    initial_value: 'false' #Default to disconnected
  - id: record_to_ha
    type: bool
    restore_value: yes #Save state across reboots
    initial_value: 'true'
  - id: spo2_current
    type: uint8_t
    restore_value: no
    initial_value: '0'
  - id: pulse_current
    type: uint8_t
    restore_value: no
    initial_value: '0'
  - id: perf_index_current
    type: uint8_t
    restore_value: no
    initial_value: '255'
  - id: battery_current
    type: uint8_t
    restore_value: no
    initial_value: '255'

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2024.6.0
  name_add_mac_suffix: false
  project:
    name: esphome.web
    version: dev
  platformio_options:
    upload_speed: 921600 #Default: 115200
    build_flags:
      - -DCONFIG_ARDUINO_LOOP_STACK_SIZE=8192
      - -DCONFIG_BT_BLE_50_FEATURES_SUPPORTED=1  # Reduce BLE features and thus size
          
esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:
  level: WARN #Default: DEBUG

# Enable Home Assistant API
api:
  on_client_connected:
    then:
      - lambda: |-
          id(ha_connected) = true;
          ESP_LOGW("ha", "HA Connected");
  on_client_disconnected:
    then:
      - lambda: |-
          id(ha_connected) = false;
          ESP_LOGW("ha", "HA Disconnected");

# Allow Over-The-Air updates
ota:
- platform: esphome

# Allow provisioning Wi-Fi via serial
improv_serial:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true #Reduce initialization time and memory usage since not scanning
  power_save_mode: LIGHT #Reduce memory/CPU usage at cost of slight delay
  
  # Set up a fallback wifi access point to configure WiFi if can't connect
  ap:
    ssid: "PC-60FW"

# In combination with the `ap` this allows the user
# to provision wifi credentials to the device via WiFi AP.
captive_portal:

#dashboard_import: #Not necessary
#  package_import_url: github://esphome/example-configs/esphome-web/esp32.yaml@main
#  import_full_config: true

# Sets up Bluetooth LE (Only on ESP32) to allow the user
# to provision wifi credentials to the device.
#esp32_improv:
#  authorizer: none

# To have a "next url" for improv serial
#web_server:
#  auth:
#    username: !secret web_server_username
#    password: !secret web_server_password

esp32_ble_tracker:
  scan_parameters:
    interval: 3000ms  # Increased from default (typically 320ms)
    window: 160ms     # Kept the same
    active: false     # Disabled

ble_client:
  - mac_address: 00:00:00:03:10:C4
    id: pc_60fw
    on_connect:
      then:
        - logger.log: 
            level: WARN
            format: "***PC-60FW connected***"
        - lambda: |-
            id(connect_start) = (int) (millis() / 1000);  // store uptime in seconds
            id(connect_oldtotal) += id(connect_length);
    on_disconnect:
      then:
        - logger.log: 
            level: WARN
            format: "***PC-60FW disconnected***"

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO0 #Boot button
      mode: INPUT_PULLUP
      inverted: true
    id: boot_button
    internal: true
    on_multi_click:
      # Short press: Toggle recording switch
      - timing:
          - ON for at most 1s
          - OFF for at least 0.1s
        then:
          - lambda: |-
              id(record_to_ha) = !id(record_to_ha);
              ESP_LOGW("boot_button", "HA Recording: %s", id(record_to_ha) ? "ON" : "OFF");
      # Long press: Reset connection times
      - timing:
          - ON for at least 2s
        then:
          - lambda: |-
              id(connect_length) = 0;
              id(connect_oldtotal) = 0;
              id(connect_start) = (int) (millis() / 1000);
              id(spo2_current) = id(pulse_current) = 0;
              id(perf_index_current) = id(battery_current) = 255;
          - logger.log:
              level: WARN
              format: "Reset connection times"  

sensor:
  # Bluetooth PC-60FW SpO2 & Pulse sensor
  - platform: ble_client
    type: characteristic
    id: internal_pulseox
    internal: true #Don't create HA entity
    ble_client_id: pc_60fw
    service_uuid: '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
    characteristic_uuid: '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
    notify: true  # Enable notifications for the characteristic
    lambda: |-
      if(x.size() == 12 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0x0F && x[3] == 0x08) {
        // Check if the data is at least 11 bytes and starts with AA 55 0F 08 01
        // Bytes 1-5: AA-55-0F-08-01 (where the 4th byte is the number of bytes following it, e.g., 8)
        // 6th byte (index 5) O2 saturation
        // 7th byte (index 6) Pulse
        // 9th byte (index 8) Perfusion index (?)
        // See: https://github.com/sza2/viatom_pc60fw
        ESP_LOGW("pulseox", "SpO2: %d\tPulse %d\tPI: %d", x[5], x[6], x[8]);
        if(x[5] != 0) { //SpO2
          id(spo2_current) = x[5];
          if(id(record_to_ha)) id(spo2).publish_state(id(spo2_current));
        }
        if(x[6] != 0) { 
          id(pulse_current) = x[6]; //Pulse
          if(id(record_to_ha)) id(pulse).publish_state(id(pulse_current));

          id(perf_index_current) = x[8]; //Pulse Index
          if(id(record_to_ha)) id(perfindex).publish_state(id(perf_index_current));
        }
      }else if (x.size() == 7 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0xF0 && x[3] == 0x3 && x[4] == 0x3) {
        // 6th byte (index 5) Battery level 0-3
        id(battery_current) = x[5];

        unsigned long uptime = millis()/1000;
        ESP_LOGW("pulsox", "Uptime: %02d:%02d:%02d Total Read: %02d:%02d:%02d  Current Read: %02d:%02d:%02d  Battery: %d",
          uptime / 3600, (uptime % 3600) / 60, uptime % 60,
          id(connect_total) / 3600, (id(connect_total) % 3600) / 60, id(connect_total) % 60,
          id(connect_length) / 3600, (id(connect_length) % 3600) / 60, id(connect_length) % 60,
          id(battery_current));
      }
      return {}; // This sensor doesn't report its own state

  - platform: template
    id: spo2
    name: "SpO2"
    icon: 'mdi:lung'
    unit_of_measurement: '%'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: pulse
    name: "Pulse"
    icon: 'mdi:heart-pulse'
    unit_of_measurement: 'bpm'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: perfindex
    name: "Perf Index"
    icon: 'mdi:waves'
    unit_of_measurement: 'PI'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

###############################################################################
#### Code for tdisplay s7789 240x135 graphical display follows
spi:
  clk_pin: GPIO18
  mosi_pin: GPIO19

font:
  - file: "gfonts://Roboto"
    id: smallfont
    size: 16

  - file: "gfonts://Roboto"
    id: medfont
    size: 25

  - file: "gfonts://Roboto"
    id: bigfont
    size: 45
    glyphs: ["%0123456789 -"]

  - file: "/config/esphome/fonts/MaterialIcons-Regular.ttf" #Note fixed size font ~24pt
    id: materialfont
    glyphs: ["\uE87D\uE1A4\uEBD4\uEBDD\uE19C"] #Heart, Battery Full, Battery 5 Bar, Battery 3 Bar, Battery Alert
 
color:
  - id: red
    red: 100%
    green: 3%
    blue: 5%

  - id: green
    red: 0%
    green: 100%
    blue: 0%

  - id: blue
    red: 0%
    green: 0%
    blue: 100%

  - id: yellow
    red: 100%
    green: 100%
    blue: 0%

  - id: orange
    red: 100%
    green: 65%
    blue: 0%

  - id: magenta
    red: 100%
    green: 0%
    blue: 100%

  - id: gray
    red: 50%
    green: 50%
    blue: 50%

display:
  - platform: ili9xxx #st7789v
    model: ST7789V #TTGO_TDisplay_135x240
    id: tft_display
    cs_pin: GPIO5
    dc_pin: GPIO16
    reset_pin: GPIO23
    dimensions:
      height: 240
      width: 135
      offset_height: 40
      offset_width: 52
    invert_colors: true # Required or the colors are all inverted, and Black screen is White
    auto_clear_enabled: false
    rotation: 270
    color_palette: 8BIT  # Use 8-bit color depth to reduce memory usage
    update_interval: ${update_interval}
    lambda: |-
      //ESP_LOGW("mem", "Heap before first print: %u", ESP.getFreeHeap());
      static bool first_draw = true;
      if(first_draw) { //Setup backlight & Print static text headings
          it.fill(Color::BLACK); //Blank screen
          it.print(0, 5, id(smallfont), id(blue), "Total Read");
          it.print(110, 5, id(smallfont), id(blue), "Current Read");
          it.print(0, 55, id(smallfont), id(blue), "SpO2");
          it.print(110, 55, id(smallfont), id(blue), "Pulse");
          it.print(200, 55, id(smallfont), id(blue), "PI");
          pinMode(4, OUTPUT);  //Ensure backlight GPIO4 is HIGH
          digitalWrite(4, HIGH); //Turn on backlight
      }

       //Print connect times
      if (id(pc_60fw).connected() || first_draw) { //Write times if connected or first draw
        if(!first_draw) { /// Update connection length
          id(connect_length) = (millis() / 1000) - id(connect_start);
          id(connect_total) = id(connect_oldtotal) + id(connect_length);
        }
        it.filled_rectangle(0, 20, 220, 25+1, Color::BLACK); //Clear time row
        it.printf(0, 20, id(medfont), id(orange), "%02d:%02d:%02d",
          (id(connect_total) / 3600)%100, (id(connect_total) % 3600) / 60, id(connect_total) % 60);
        it.printf(110, 20, id(medfont), id(orange), "%02d:%02d:%02d",
          (id(connect_length) / 3600)%100, (id(connect_length) % 3600) / 60, id(connect_length) % 60);
      }

      //Print Battery usage
      static uint8_t battery_prev = id(battery_current);
      if(id(battery_current) != battery_prev) {
        if(id(battery_current) == 255)
          it.filled_rectangle(218, 26, 22, 24+1, Color::BLACK); //Clear Battery guage
        else if(id(battery_current) == 3)
          it.print(218, 26, id(materialfont), id(green), "\uE1A4"); // Battery Full
        else if(id(battery_current) == 2)
          it.print(218, 26, id(materialfont), id(green), "\uEBD4"); // Battery 5-Bar
        else if(id(battery_current) == 1)
          it.print(218, 26, id(materialfont), id(yellow), "\uEBDD"); // Battery 3-Bar
        else
          it.print(218, 26, id(materialfont), id(red), "\uE19C"); // Battery Alert

        battery_prev = id(battery_current);
      }

       //Print SpO2
      Color color;
      static uint8_t(spo2_prev) = 255;
      if(id(spo2_current) != spo2_prev) {
        it.filled_rectangle(0, 70, 110, 45+1, Color::BLACK); //Clear SpO2
        if(id(spo2_current)) {
            if(id(spo2_current) >= ${sat_lo}) color = id(green);
            else if(id(spo2_current) >= ${sat_vlo}) color = id(yellow);
            else color = id(red);

            it.printf(0, 70, id(bigfont), color, "%d%%", id(spo2_current));
        } else
            it.print(0, 70, id(bigfont), id(gray), "---%");

        spo2_prev = id(spo2_current);
      }

      //Print Pulse
      static uint8_t(pulse_prev) = 255;
      if(id(pulse_current) != pulse_prev) {
        it.filled_rectangle(110, 70, 89, 45+1, Color::BLACK); //Clear Pulse
        if(id(pulse_current)) {
          if(id(pulse_current) > ${pulse_vhi}) color = id(red);
          else if(id(pulse_current) > ${pulse_hi}) color = id(yellow);
          else if(id(pulse_current) >= ${pulse_lo} ) color = id(green);
          else if(id(pulse_current) >= ${pulse_vlo} ) color = id(yellow);
          else color = id(red);

          it.printf(110, 70, id(bigfont), color, "%d", id(pulse_current));
        } else
          it.print(110, 70, id(bigfont), id(gray), "---");
        pulse_prev = id(pulse_current);
      }

      //Print Pulse Index
      static uint8_t(perf_index_prev) = 254;
      if(id(perf_index_current) != perf_index_prev) {
        it.filled_rectangle(199, 80, 41, 25+1, Color::BLACK); //Clear Pulse Index
        if(id(perf_index_current) != 255) {
          if(id(perf_index_current) > ${perf_index_med}) color = id(green);
          else if(id(perf_index_current) > ${perf_index_lo}) color = id(orange);
          else color = id(red);
          it.printf(199, 80, id(medfont),  color, "%3d", id(perf_index_current));
        } else
          it.print(199, 80, id(medfont),  id(gray), "---");
        perf_index_prev = id(perf_index_current);
      }

      //Print HA Connect status
      static bool ha_connected_prev = !id(ha_connected);
      if(id(ha_connected) != ha_connected_prev) {
          id(tft_display).filled_rectangle(0, 120, 110, 15, Color::BLACK); //Clear display area
          id(tft_display).print(0, 120, id(smallfont), id(magenta), id(ha_connected) ? "HA ALIVE" : "HA DROP");
          ha_connected_prev = id(ha_connected);
      }

      // Print HA Recording status
      static uint8_t record_state = 255; //0= NO RECORD; 1=RECORD; 2=RECORDING
      if(id(ha_connected) && id(pc_60fw).connected() && id(record_to_ha)) {
        if(record_state != 2) {
          it.filled_rectangle(110, 120, 105, 15, Color::BLACK);
          it.print(110, 120, id(smallfont), id(red), "RECORDING");
        record_state = 2;
        }
      }else if(id(record_to_ha) != record_state) {
          it.filled_rectangle(110, 120, 105, 15, Color::BLACK);
          it.print(110, 120, id(smallfont), id(magenta), id(record_to_ha) ? "RECORD" : "NO RECORD");
          record_state = id(record_to_ha);
      }

      // Print blinking "heartbeat" to show device is alive
      static bool blink = true;
      blink = not(blink); //Toggle every other display
      if(blink)
        it.print(215, 117, id(materialfont), id(pc_60fw).connected() ? id(red) : id(blue), "\uE87D");
      else
        it.filled_rectangle(215, 117, 24+1, 24+1, Color::BLACK);
      
      ESP_LOGI("mem", "Heap after final print: %u", ESP.getFreeHeap());
      first_draw = false;
2 Likes

I have to use boot button to get this 2 sensor? I want to place my esp32 in plastic box.

I have built many boxes for the T-display where I have a cutout for the display plus 2 holes that hold plastic buttons that in turn push on the 2 buttons next to the display – All 3D printed

If I don’t use the monitor, I don’t need these 2 buttons? Will I be able to monitor the metrics in the HA without pressing these buttons?

Do I need to press these 2 buttons if I don’t use the monitor? When I turn on the Pulse Oximeter, will I immediately see the readings in the XA without pressing these buttons?

Buttons are helpful to turn on/off recording. Right now those buttons are not displayed in HA as its best to control from the esp32 device (and pretty much all devices have that button). If you want the button to also exist in HA then change internal: true to false or just delete the line.

Also it remembers previous state so if you have it set once, it will start recording.

Hi, thank you for work
What is perf index?

Perf Index = Perfusion index = measure of how good the plethysmography signal is and indirectly therefore the perfusion (or at least how good a signal it is getting).

Is it possible to write such a config for the tonometer medisana bu 575? If yes, then please tell me what needs to be added or corrected?

1 Like

I have slightly modified the firmware. Now the Perf Index is displayed correctly. It is measured as a percentage and should be between 0.02% and 20.0%. I have also added a battery sensor that ranges from 0 to 3, with 3 representing a full charge and 0 representing a low battery level.

substitutions: 
  update_interval: 1s  # Update frequency for sensors and display

globals:
  - id: connect_length
    type: int
    restore_value: no   
  - id: connect_total
    type: int
    restore_value: no    
  - id: connect_oldtotal
    type: int
    restore_value: no    
  - id: ha_connected
    type: bool
    restore_value: no
    initial_value: 'false' #Default to disconnected
  - id: record_to_ha
    type: bool
    restore_value: yes #Save state across reboots
    initial_value: 'true'
  - id: spo2_current
    type: uint8_t
    restore_value: no    
  - id: pulse_current
    type: uint8_t
    restore_value: no    
  - id: perf_index_current
    type: uint8_t
    restore_value: no    
  - id: battery_current
    type: uint8_t
    restore_value: no    

esphome:
  name: pulsometer
  friendly_name: pulsometer
  min_version: 2024.6.0
  name_add_mac_suffix: false
  project:
    name: esphome.web
    version: dev
  platformio_options:
    upload_speed: 115200
    build_flags:
      - -DCONFIG_ARDUINO_LOOP_STACK_SIZE=8192
      - -DCONFIG_BT_BLE_50_FEATURES_SUPPORTED=1  # Reduce BLE features and thus size
          
esp32:
  board: esp32dev
  framework:
    type: esp-idf

logger:
  level: WARN #Default: DEBUG

# Enable Home Assistant API
api:
  encryption:
    key: "uzDQxIIqnr7wkBa/uBpNzVPgmO9tiur5FBwCmA4UJa0="

# Allow Over-The-Air updates
ota:
  - platform: esphome
    password: "xxxxxxxxxxxxxxx"

# Allow provisioning Wi-Fi via serial
improv_serial:

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true #Reduce initialization time and memory usage since not scanning
  power_save_mode: LIGHT #Reduce memory/CPU usage at cost of slight delay  
  manual_ip:
    static_ip: 192.168.0.12
    gateway: 192.168.0.1 
    subnet: 255.255.255.0
    dns1: 192.168.0.1

  ap:
    ssid: "Pulsometer Fallback Hotspot"
    password: "xxxxxxxxxxxxxxx"

# In combination with the `ap` this allows the user
# to provision wifi credentials to the device via WiFi AP.
captive_portal:

esp32_ble_tracker:
  scan_parameters:
    interval: 3200ms  # Increased from default (typically 320ms)
    window: 160ms     # Kept the same
    active: false     # Disabled

ble_client:
  - mac_address: 00:00:00:03:10:4E
    id: pc_60fw

sensor:
  # Bluetooth PC-60FW SpO2 & Pulse sensor
  - platform: ble_client
    type: characteristic
    id: internal_pulseox
    internal: true #Don't create HA entity
    ble_client_id: pc_60fw
    service_uuid: '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
    characteristic_uuid: '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
    notify: true  # Enable notifications for the characteristic
    lambda: |-
      if(x.size() == 12 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0x0F && x[3] == 0x08) {
        // Check if the data is at least 11 bytes and starts with AA 55 0F 08 01
        // Bytes 1-5: AA-55-0F-08-01 (where the 4th byte is the number of bytes following it, e.g., 8)
        // 6th byte (index 5) O2 saturation
        // 7th byte (index 6) Pulse
        // 9th byte (index 8) Perfusion index (?)
        // See: https://github.com/sza2/viatom_pc60fw
        ESP_LOGW("pulseox", "SpO2: %d\tPulse %d\tPI: %d", x[5], x[6], x[8]);
        if(x[5] != 0) { //SpO2
          id(spo2_current) = x[5];
          if(id(record_to_ha)) id(spo2).publish_state(id(spo2_current));
        }
        if(x[6] != 0) { //Pulse
          id(pulse_current) = x[6]; 
          if(id(record_to_ha)) id(pulse).publish_state(id(pulse_current));
        }
        if(x[8] != 0) { //Perf  Index
          id(perf_index_current) = x[8]; 
          if(id(record_to_ha)) id(perfindex).publish_state(id(perf_index_current)/10);
        }
      }else if (x.size() == 7 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0xF0 && x[3] == 0x3 && x[4] == 0x3) {
        // 6th byte (index 5) Battery level 0-3
        if(x[5] != 0) { //Battery
          id(battery_current) = x[5];
          if(id(record_to_ha)) id(battery_level).publish_state(id(battery_current));
        }       
        unsigned long uptime = millis()/1000;
        ESP_LOGW("pulsox", "Uptime: %02d:%02d:%02d Total Read: %02d:%02d:%02d  Current Read: %02d:%02d:%02d  Battery: %d",
          uptime / 3600, (uptime % 3600) / 60, uptime % 60,
          id(connect_total) / 3600, (id(connect_total) % 3600) / 60, id(connect_total) % 60,
          id(connect_length) / 3600, (id(connect_length) % 3600) / 60, id(connect_length) % 60,
          id(battery_current));
      }
      return {}; // This sensor doesn't report its own state

  - platform: template
    id: spo2
    name: "SpO2"
    icon: 'mdi:lung'
    unit_of_measurement: '%'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: pulse
    name: "Pulse"
    icon: 'mdi:heart-pulse'
    unit_of_measurement: 'bpm'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: perfindex
    name: "Perf Index"
    icon: 'mdi:waves'
    unit_of_measurement: '%'
    accuracy_decimals: 0
    filters:
      - delta: 0.1  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: battery_level
    name: "Battery level"
    icon: 'mdi:battery'    
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency   

  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 60s

text_sensor:
  - platform: wifi_info
    ip_address:
      name: IP Address         
    ssid:
      name: SSID    
    mac_address:
      name: Mac Address      

binary_sensor:
  - platform: status
    name: "Status"               

Will I able to use this device at https://www.walmart.com/ip/Wellue-Pulse-Oximeter-Bluetooth-Oxygen-Finger-Monitor-and-Fingertip-Pulse-Rate-Measure-with-Free-App-Carry-Case-and-Lanyard-PC60FW/881934693 with home assistant? Do I need to flash it with your firmware? Or just use esphome? I have esphome installed on my home assistant hub.

You need an esp32 to flash with the esphome firmware – best to use the t-display so the display works.

Can’t guarantee yours will work but may very well if it isa PC-60FW

Updated code for tdisplay:

##### PulseOx-PC60FW-ble
# Jeff Kosowsky
# Version 1.0.0
# August 2025
# Copyright 2025
###############################################################################
# DESCRIPTION:
#   Read from bluetooth-enabled PC60FW Pulse Oximeter and display on an esp32
#   t-display with 240x135 pixel OLED tdisplay
#   pixel OLED tdisplay
#
## NOTES:
# - HA Sensors created:
#     SpO2: sensor.pc_60fw_spo2 (in percent)
#     Pulse: sensor.pc_60fw_pulse (in bpm)
#     Perfusion Index: sensor.pc_60fw_perf_index (in % * 10)
#
# - Display layout:
#     Top Row: Total sensor read time and current read time
#              plus 4-state battery level icon (green if full, red empty, yellow otherwise)
#     Middle Row: Large SpO2 and Pulse reading plus smaller Perfusion Index reading
#                 (green/yellow/red based on ranges set below)
#         Note that current values displayed regardless of whether HA connected and/or recording
#         If PC-60FW not connected, then last valid values displayed
#     Bottom Row: HA ALIVE/HA DROP
#                 NO RECORD/RECORD/RECORDING (red if recording)
#                 Pulsing heart (every second): Red if PC-60FW connected, blue otherwise
#
# - Boot Button:
#     Short press  to toggle recording to HA on/off
#     Long press to reset connected times and data values to zero
#
## NOTE: you also need to set the following variables in `secrets.yaml`:
#  wifi_ssid, wifi_password, web_server_username, web_server_password
#  NOTE: Currently commented-out excluded to conserve precious SRAM
#
# References on decoding PC60fw:
#     https://github.com/sza2/viatom_pc60fw
#
###############################################################################
#### Non-Display-specific code

substitutions:
  name: pc-60fw
  friendly_name: PC-60FW PulseOx
  update_interval: 1s  # Update frequency for sensors and display
  # Following determine range of SpO2 and Pulse ranges [USED ONLY for graphical displays]
  sat_lo: "95"
  sat_vlo: "90"
  pulse_vhi: "100"
  pulse_hi: "75"
  pulse_lo: "55"
  pulse_vlo: "40"
  perf_index_lo: "20"
  perf_index_med: "60"

globals:
  - id: connect_start
    type: int
    restore_value: false
    initial_value: '-1'
  - id: connect_length
    type: int
    restore_value: false
    initial_value: '0'
  - id: connect_total
    type: int
    restore_value: false
    initial_value: '0'
  - id: connect_oldtotal
    type: int
    restore_value: false
    initial_value: '0'
  - id: ha_connected
    type: bool
    restore_value: false
    initial_value: 'false'  # Default to disconnected
  - id: record_to_ha
    type: bool
    restore_value: true  # Save state across reboots
    initial_value: 'true'
  - id: spo2_current
    type: uint8_t
    restore_value: false
    initial_value: '0'
  - id: pulse_current
    type: uint8_t
    restore_value: false
    initial_value: '0'
  - id: perf_index_current
    type: uint8_t
    restore_value: false
    initial_value: '255'
  - id: battery_current
    type: uint8_t
    restore_value: false
    initial_value: '255'

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2024.6.0
  name_add_mac_suffix: false
  project:
    name: esphome.web
    version: dev
  platformio_options:
  #  board_build.partitions: huge_app.csv # App space: ~1.8MB default vs. ~1.9MB min_spiffs.csv vs. ~3MB huge_app.csv (Arduino only)
    upload_speed: 921600  # Default: 115200
    build_flags:
      - -DCONFIG_ARDUINO_LOOP_STACK_SIZE=8192
      - -DCONFIG_BT_BLE_50_FEATURES_SUPPORTED=1  # Reduce BLE features and thus size

esp32:
  board: esp32dev
  framework:
    type: esp-idf #Note: 'ble' is currently unsable with 'arduino'; Also code is smaller and uses less memory

# Enable logging
logger:
  level: WARN  # Set to 'WARN'; Use 'INFO' to get heap sizes

# Enable Home Assistant API
api:
  on_client_connected:
    then:
      - lambda: |-
          id(ha_connected) = true;
          ESP_LOGW("ha", "HA Connected");
  on_client_disconnected:
    then:
      - lambda: |-
          id(ha_connected) = false;
          ESP_LOGW("ha", "HA Disconnected");

  reboot_timeout: 600s  # Reboot only if disconnected from HA for more than 10 minutes

# Allow Over-The-Air updates
ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  reboot_timeout: 600s  # Reboot only if disconnected from Wifi for more than 10 minutes

  fast_connect: true  # Reduce initialization time and memory usage since not scanning
  power_save_mode: LIGHT  # Reduce memory/CPU usage at cost of slight delay

  # Set up a fallback WiFi access point to configure WiFi if can't connect
  ap:
    ssid: "PC-60FW"
    password: !secret ap_password

# Allow provisioning WiFi via AP defined above
captive_portal:

# Allow provisioning WiFi via serial
#improv_serial:

# Allow provisioning WiFi over Bluetooth LE (Only on ESP32)
#esp32_improv:
#  authorizer: none

# Log and interact via custom web server
web_server:
  auth:
    username: !secret web_server_username
    password: !secret web_server_password

#dashboard_import: #Not necessary
#  package_import_url: github://esphome/example-configs/esphome-web/esp32.yaml@main
#  import_full_config: true

esp32_ble_tracker:
  scan_parameters:
    interval: 3000ms  # Increased from default (typically 320ms)
    window: 160ms     # Kept the same
    active: false     # Disabled

ble_client:
  - mac_address: 00:00:00:03:10:C4
    id: pc_60fw
    on_connect:
      then:
        - logger.log:
            level: WARN
            format: "***PC-60FW connected***"
        - lambda: |-
            id(connect_start) = (int) (millis() / 1000);  // store uptime in seconds
            id(connect_oldtotal) += id(connect_length);
    on_disconnect:
      then:
        - logger.log:
            level: WARN
            format: "***PC-60FW disconnected***"

button:
  - platform: restart
    name: "Restart Device"
    disabled_by_default: true

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO0  # Boot button
      mode: INPUT_PULLUP
      inverted: true
    id: boot_button
    internal: true
    on_multi_click:
      # Short press: Toggle recording switch
      - timing:
          - ON for at most 1s
          - OFF for at least 0.1s
        then:
          - lambda: |-
              id(record_to_ha) = !id(record_to_ha);
              ESP_LOGW("boot_button", "HA Recording: %s", id(record_to_ha) ? "ON" : "OFF");
      # Long press: Reset connection times
      - timing:
          - ON for at least 2s
        then:
          - lambda: |-
              id(connect_length) = 0;
              id(connect_oldtotal) = 0;
              id(connect_start) = (int) (millis() / 1000);
              id(spo2_current) = id(pulse_current) = 0;
              id(perf_index_current) = id(battery_current) = 255;
          - logger.log:
              level: WARN
              format: "Reset connection times"

sensor:
  - platform: uptime
    name: "Uptime"
    id: device_uptime
    update_interval: 120s  # Update uptime every 2 minutes
    disabled_by_default: true

  # Bluetooth PC-60FW SpO2 & Pulse sensor
  - platform: ble_client
    type: characteristic
    id: internal_pulseox
    internal: true  # Don't create HA entity
    ble_client_id: pc_60fw
    service_uuid: '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
    characteristic_uuid: '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
    notify: true  # Enable notifications for the characteristic
    lambda: |-
      if(x.size() == 12 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0x0F && x[3] == 0x08) {
        // Check if the data is at least 11 bytes and starts with AA 55 0F 08 01
        // Bytes 1-5: AA-55-0F-08-01 (where the 4th byte is the number of bytes following it, e.g., 8)
        // 6th byte (index 5) O2 saturation
        // 7th byte (index 6) Pulse
        // 9th byte (index 8) Perfusion index (?)
        // See: https://github.com/sza2/viatom_pc60fw
        ESP_LOGW("pulseox", "SpO2: %d\tPulse %d\tPI: %d", x[5], x[6], x[8]);
        if(x[5] != 0) { //SpO2
          id(spo2_current) = x[5];
          if(id(record_to_ha)) id(spo2).publish_state(id(spo2_current));
        }
        if(x[6] != 0) {
          id(pulse_current) = x[6]; //Pulse
          if(id(record_to_ha)) id(pulse).publish_state(id(pulse_current));

          id(perf_index_current) = x[8]; //Pulse Index
          if(id(record_to_ha)) id(perfindex).publish_state(id(perf_index_current));
        }
      }else if (x.size() == 7 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0xF0 && x[3] == 0x3 && x[4] == 0x3) {
        // 6th byte (index 5) Battery level 0-3
        id(battery_current) = x[5];

        unsigned long uptime = millis()/1000;
        ESP_LOGW("pulsox", "Uptime: %02d:%02d:%02d Total Read: %02d:%02d:%02d  Current Read: %02d:%02d:%02d  Battery: %d",
          uptime / 3600, (uptime % 3600) / 60, uptime % 60,
          id(connect_total) / 3600, (id(connect_total) % 3600) / 60, id(connect_total) % 60,
          id(connect_length) / 3600, (id(connect_length) % 3600) / 60, id(connect_length) % 60,
          id(battery_current));
      }
      return {}; // This sensor doesn't report its own state

  - platform: template
    id: spo2
    name: "SpO2"
    icon: 'mdi:lung'
    unit_of_measurement: '%'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: pulse
    name: "Pulse"
    icon: 'mdi:heart-pulse'
    unit_of_measurement: 'bpm'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

  - platform: template
    id: perfindex
    name: "Perf Index"
    icon: 'mdi:waves'
    unit_of_measurement: 'PI'
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency

###############################################################################
#### Code for tdisplay s7789 240x135 graphical display follows
spi:
  clk_pin: GPIO18
  mosi_pin: GPIO19

font:
  - file: "gfonts://Roboto"
    id: smallfont
    size: 16

  - file: "gfonts://Roboto"
    id: medfont
    size: 25

  - file: "gfonts://Roboto"
    id: bigfont
    size: 45
    glyphs: ["%0123456789 -"]

  - file: "/config/esphome/fonts/MaterialIcons-Regular.ttf"  # Note fixed size font ~24pt
    id: materialfont
    glyphs: ["\uE87D\uE1A4\uEBD4\uEBDD\uE19C"]  # Heart, Battery Full, Battery 5 Bar, Battery 3 Bar, Battery Alert

color:
  - id: red
    red: 100%
    green: 3%
    blue: 5%

  - id: green
    red: 0%
    green: 100%
    blue: 0%

  - id: blue
    red: 0%
    green: 0%
    blue: 100%

  - id: yellow
    red: 100%
    green: 100%
    blue: 0%

  - id: orange
    red: 100%
    green: 65%
    blue: 0%

  - id: magenta
    red: 100%
    green: 0%
    blue: 100%

  - id: gray
    red: 50%
    green: 50%
    blue: 50%

output:
  - platform: gpio
    id: backlight
    pin: GPIO4

display:
  - platform: ili9xxx  # st7789v
    model: ST7789V  # TTGO_TDisplay_135x240
    id: tft_display
    cs_pin: GPIO5
    dc_pin: GPIO16
    reset_pin: GPIO23
    dimensions:
      height: 240
      width: 135
      offset_height: 40
      offset_width: 52
    invert_colors: true  # Required or the colors are all inverted, and Black screen is White
    auto_clear_enabled: false
    rotation: 270
    color_palette: 8BIT  # Use 8-bit color depth to reduce memory usage
    update_interval: ${update_interval}
    lambda: |-
      #ifdef USE_ARDUINO
        ESP_LOGI("mem", "Heap Before: Free: %u", ESP.getFreeHeap());
      #else
        ESP_LOGI("mem", "Heap After: Free: %u", heap_caps_get_free_size(MALLOC_CAP_8BIT));
      #endif

      static bool first_draw = true;
      if(first_draw) { //Setup backlight & Print static text headings
          it.fill(Color::BLACK); //Blank screen
          it.print(0, 5, id(smallfont), id(blue), "Total Read");
          it.print(110, 5, id(smallfont), id(blue), "Current Read");
          it.print(0, 55, id(smallfont), id(blue), "SpO2");
          it.print(110, 55, id(smallfont), id(blue), "Pulse");
          it.print(200, 55, id(smallfont), id(blue), "PI %");
          id(backlight).turn_on();
      }

      //Print connect times
      static int connect_length_prev = 0;
      if (id(pc_60fw).connected() || first_draw) { //Write times if connected or first draw
        if(!first_draw) { /// Update connection length
          id(connect_length) = (millis() / 1000) - id(connect_start);
          id(connect_total) = id(connect_oldtotal) + id(connect_length);
        }
        if (id(connect_length) != connect_length_prev) {
          it.filled_rectangle(0, 20, 220, 25+1, Color::BLACK); //Clear time row
          it.printf(0, 20, id(medfont), id(orange), "%02d:%02d:%02d",
            (id(connect_total) / 3600)%100, (id(connect_total) % 3600) / 60, id(connect_total) % 60);
          it.printf(110, 20, id(medfont), id(orange), "%02d:%02d:%02d",
            (id(connect_length) / 3600)%100, (id(connect_length) % 3600) / 60, id(connect_length) % 60);
        }
        connect_length_prev = id(connect_length);
      }

      //Print Battery usage
      static uint8_t battery_prev = id(battery_current);
      if(id(battery_current) != battery_prev) {
        if(id(battery_current) == 255)
          it.filled_rectangle(218, 26, 22, 24+1, Color::BLACK); //Clear Battery guage
        else if(id(battery_current) == 3)
          it.print(218, 26, id(materialfont), id(green), "\uE1A4"); // Battery Full
        else if(id(battery_current) == 2)
          it.print(218, 26, id(materialfont), id(green), "\uEBD4"); // Battery 5-Bar
        else if(id(battery_current) == 1)
          it.print(218, 26, id(materialfont), id(yellow), "\uEBDD"); // Battery 3-Bar
        else
          it.print(218, 26, id(materialfont), id(red), "\uE19C"); // Battery Alert

        battery_prev = id(battery_current);
      }

       //Print SpO2
      Color color;
      static uint8_t(spo2_prev) = 255;
      if(id(spo2_current) != spo2_prev) {
        it.filled_rectangle(0, 70, 110, 45+1, Color::BLACK); //Clear SpO2
        if(id(spo2_current)) {
            if(id(spo2_current) >= ${sat_lo}) color = id(green);
            else if(id(spo2_current) >= ${sat_vlo}) color = id(yellow);
            else color = id(red);

            it.printf(0, 70, id(bigfont), color, "%d%%", id(spo2_current));
        } else
            it.print(0, 70, id(bigfont), id(gray), "---%");

        spo2_prev = id(spo2_current);
      }

      //Print Pulse
      static uint8_t(pulse_prev) = 255;
      if(id(pulse_current) != pulse_prev) {
        it.filled_rectangle(110, 70, 84, 45+1, Color::BLACK); //Clear Pulse
        if(id(pulse_current)) {
          if(id(pulse_current) > ${pulse_vhi}) color = id(red);
          else if(id(pulse_current) > ${pulse_hi}) color = id(yellow);
          else if(id(pulse_current) >= ${pulse_lo} ) color = id(green);
          else if(id(pulse_current) >= ${pulse_vlo} ) color = id(yellow);
          else color = id(red);

          it.printf(110, 70, id(bigfont), color, "%d", id(pulse_current));
        } else
          it.print(110, 70, id(bigfont), id(gray), "---");
        pulse_prev = id(pulse_current);
      }

      //Print Pulse Index
      static uint8_t(perf_index_prev) = 254;
      if(id(perf_index_current) != perf_index_prev) {
        it.filled_rectangle(194, 78, 46, 25+1, Color::BLACK); //Clear Pulse Index
        if(id(perf_index_current) != 255) {
          if(id(perf_index_current) > ${perf_index_med}) color = id(green);
          else if(id(perf_index_current) > ${perf_index_lo}) color = id(orange);
          else color = id(red);
          it.printf(194, 78, id(medfont),  color, "%4.1f", id(perf_index_current)/10.0);
        } else
          it.print(194, 78, id(medfont),  id(gray), "---");
        perf_index_prev = id(perf_index_current);
      }

      //Print HA Connect status
      static bool ha_connected_prev = !id(ha_connected);
      if(id(ha_connected) != ha_connected_prev) {
          id(tft_display).filled_rectangle(0, 120, 110, 15, Color::BLACK); //Clear display area
          id(tft_display).print(0, 120, id(smallfont), id(magenta), id(ha_connected) ? "HA ALIVE" : "HA DROP");
          ha_connected_prev = id(ha_connected);
      }

      // Print HA Recording status
      static uint8_t record_state = 255; //0= NO RECORD; 1=RECORD; 2=RECORDING
      if(id(ha_connected) && id(pc_60fw).connected() && id(record_to_ha)) {
        if(record_state != 2) {
          it.filled_rectangle(110, 120, 105, 15, Color::BLACK);
          it.print(110, 120, id(smallfont), id(red), "RECORDING");
        record_state = 2;
        }
      }else if(id(record_to_ha) != record_state) {
          it.filled_rectangle(110, 120, 105, 15, Color::BLACK);
          it.print(110, 120, id(smallfont), id(magenta), id(record_to_ha) ? "RECORD" : "NO RECORD");
          record_state = id(record_to_ha);
      }

      // Print blinking "heartbeat" to show device is alive
      static bool blink = true;
      blink = not(blink); //Toggle every other display
      if(blink)
        it.print(215, 117, id(materialfont), id(pc_60fw).connected() ? id(red) : id(blue), "\uE87D");
      else
        it.filled_rectangle(215, 117, 24+1, 24+1, Color::BLACK);

      first_draw = false;

      #ifdef USE_ARDUINO
        ESP_LOGI("mem", "Heap After: Free: %u, Min: %u",
                ESP.getFreeHeap(),
                ESP.getMinFreeHeap());
      #else
        ESP_LOGI("mem", "Heap After: Free: %u, Min: %u",
                heap_caps_get_free_size(MALLOC_CAP_8BIT),
                heap_caps_get_minimum_free_size(MALLOC_CAP_8BIT));
      #endif

Here is an updated version (1.1.0) incorporating multiple minor improvements:

  • Added expire_after so that data becomes ‘unknown’ when disconnected for more than 120s (configurable) since device it typically only intermittenttly connected
  • Added state_class measurement (to trigger statistics) and
  • Added optional sensor to record battery level to HA (in addition to showing on display) - suggested by De_Andry
  • Added optional switch to toggle recording in HA (in addition to buttons on esp32 itself) - suggested by De_Andry
  • Changed unit of stored HA perfusion index to percent (rather than raw) to match display percent - suggested by De_Andry
  • Several minor bug fixes and code optimizations
##### PulseOx-PC60FW-ble
# Jeff Kosowsky
# Version 1.1.0
# Jan 2026
# Copyright 2025-2026
###############################################################################
# DESCRIPTION:
#   Read from bluetooth-enabled PC60FW Pulse Oximeter and display on an esp32
#   t-display with 240x135 pixel OLED tdisplay
#
## NOTES:
# - HA Sensors created:
#     SpO2: sensor.pc_60fw_spo2 (in percent)
#     Pulse: sensor.pc_60fw_pulse (in bpm)
#     Perfusion Index: sensor.pc_60fw_perf_index (in %)
#     [Optional] Battery Level:  (0-3 where 0=Empty, 3=Full)  [change disabled_by_default to 'false' to enable]
#     [Optional] Record to HA: switch.pc_60fw_record (bool)  [change disabled_by_default to 'false' to enable]
#
# - Display layout:
#     Top Row: Total sensor read time and current read time
#              plus 4-state battery level icon (green if full, red empty, yellow otherwise)
#     Middle Row: Large SpO2 and Pulse reading plus smaller Perfusion Index reading
#                 (green/yellow/red based on ranges set below)
#         Note that current values displayed regardless of whether HA connected and/or recording
#         If PC-60FW not connected, then last valid values displayed
#     Bottom Row: HA ALIVE/HA DROP
#                 NO RECORD/RECORD/RECORDING (red if recording)
#                 Pulsing heart (every second): Red if PC-60FW connected, blue otherwise
#
# - Boot Button:
#     Short press  to toggle recording to HA on/off
#     Long press to reset connected times and data values to zero
#
## NOTE: you also need to set the following variables in `secrets.yaml`:
#  wifi_ssid, wifi_password, web_server_username, web_server_password
#  NOTE: Currently commented-out excluded to conserve precious SRAM
#
# References on decoding PC60fw:
#     https://github.com/sza2/viatom_pc60fw
#
###############################################################################
#### Non-Display-specific code

substitutions:
  name: pc-60fw
  friendly_name: PC-60FW PulseOx
  update_interval: 1s  # Update frequency for sensors and display
  expire_after: 120s  # Expire sensors after this amount of time of no new data since intermittent recording
  # Following determine range of SpO2 and Pulse ranges [USED ONLY for graphical displays]
  sat_lo: "95"
  sat_vlo: "90"
  pulse_vhi: "100"
  pulse_hi: "75"
  pulse_lo: "55"
  pulse_vlo: "40"
  perf_index_lo: "20"  # Note these are multiplied by 10 to give ints - e.g., 20 -> 2.0%
  perf_index_med: "60"

globals:
  - id: connect_start
    type: int
    restore_value: false
    initial_value: '-1'  # Invalid sentinel value
  - id: connect_length
    type: int
    restore_value: false
    initial_value: '0'
  - id: connect_total
    type: int
    restore_value: false
    initial_value: '0'
  - id: connect_oldtotal
    type: int
    restore_value: false
    initial_value: '0'
  - id: ha_connected
    type: bool
    restore_value: false
    initial_value: 'false'  # Default to disconnected
  - id: record_to_ha
    type: bool
    restore_value: true  # Save state across reboots
    initial_value: 'true'
  - id: spo2_current
    type: uint8_t
    restore_value: false
    initial_value: '0'  # Invalid sentinel value
  - id: pulse_current
    type: uint8_t
    restore_value: false
    initial_value: '0'
  - id: perf_index_current
    type: uint8_t
    restore_value: false
    initial_value: '255'  # Invalid sentinel value
  - id: battery_current
    type: uint8_t
    restore_value: false
    initial_value: '255'  # Invalid sentinel value

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2024.6.0
  name_add_mac_suffix: false
  project:
    name: esphome.web
    version: dev
  platformio_options:
#   board_build.partitions: huge_app.csv  # App space: ~1.8MB default vs. ~1.9MB min_spiffs.csv vs. ~3MB huge_app.csv (Arduino only)
    upload_speed: 921600  # Default: 115200
    build_flags:
      - -DCONFIG_ARDUINO_LOOP_STACK_SIZE=8192
      - -DCONFIG_BT_BLE_50_FEATURES_SUPPORTED=1  # Reduce BLE features and thus size

esp32:
  board: esp32dev
  framework:
    type: esp-idf  # Note: 'ble' is currently unsable with 'arduino'; Also code is smaller and uses less memory

# Enable logging
logger:
  level: WARN  # Set to 'WARN'; Use 'INFO' to get heap sizes

# Enable Home Assistant API
api:
  on_client_connected:
    then:
      - lambda: |-
          id(ha_connected) = true;
          ESP_LOGW("ha", "HA Connected");
  on_client_disconnected:
    then:
      - lambda: |-
          id(ha_connected) = false;
          ESP_LOGW("ha", "HA Disconnected");

  reboot_timeout: 600s  # Reboot only if disconnected from HA for more than 10 minutes

# Allow Over-The-Air updates
ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  reboot_timeout: 600s  # Reboot only if disconnected from Wifi for more than 10 minutes

  fast_connect: true  # Reduce initialization time and memory usage since not scanning
  power_save_mode: LIGHT  # Reduce memory/CPU usage at cost of slight delay

  # Set up a fallback WiFi access point to configure WiFi if can't connect
  ap:
    ssid: "PC-60FW"
    password: !secret ap_password

# Allow provisioning WiFi via AP defined above
captive_portal:

# Allow provisioning WiFi via serial
#improv_serial:

# Allow provisioning WiFi over Bluetooth LE (Only on ESP32)
#esp32_improv:
#  authorizer: none

# Log and interact via custom web server
web_server:
  auth:
    username: !secret web_server_username
    password: !secret web_server_password

#dashboard_import:  # Not necessary
#  package_import_url: github://esphome/example-configs/esphome-web/esp32.yaml@main
#  import_full_config: true

mqtt:  # NOT USED - just added so that expire_after works
  broker: 127.0.0.1

esp32_ble_tracker:
  scan_parameters:
    interval: 3000ms  # Increased from default (typically 320ms)
    window: 160ms     # Kept the same
    active: false     # Disabled

ble_client:
  - mac_address: 00:00:00:03:10:C4
    id: pc_60fw
    on_connect:
      then:
        - logger.log:
            level: WARN
            format: "***PC-60FW connected***"
        - lambda: |-
            id(connect_start) = (int) (millis() / 1000);  // Store uptime in seconds
            id(connect_oldtotal) += id(connect_length);
    on_disconnect:
      then:
        - logger.log:
            level: WARN
            format: "***PC-60FW disconnected***"

button:
  - platform: restart
    name: "Restart Device"
    disabled_by_default: true

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO0  # Boot button
      mode: INPUT_PULLUP
      inverted: true
    id: boot_button
    internal: true
    on_multi_click:
      # Short press: Toggle recording switch
      - timing:
          - ON for at most 1s
          - OFF for at least 0.1s
        then:
          - lambda: |-
              id(record_to_ha) = !id(record_to_ha);
              ESP_LOGW("boot_button", "HA Recording: %s", id(record_to_ha) ? "ON" : "OFF");
      # Long press: Reset connection times
      - timing:
          - ON for at least 2s
        then:
          - lambda: |-
              id(connect_length) = 0;
              id(connect_oldtotal) = 0;
              id(connect_start) = (int) (millis() / 1000);
              id(spo2_current) = id(pulse_current) = 0;
              id(perf_index_current) = id(battery_current) = 255;
          - logger.log:
              level: WARN
              format: "Reset connection times"

sensor:
  - platform: uptime
    name: "Uptime"
    id: device_uptime
    update_interval: 120s  # Update uptime every 2 minutes
    disabled_by_default: true

  # Bluetooth PC-60FW SpO2 & Pulse sensor
  - platform: ble_client
    type: characteristic
    id: internal_pulseox
    internal: true  # Don't create HA entity
    ble_client_id: pc_60fw
    service_uuid: '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
    characteristic_uuid: '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
    notify: true  # Enable notifications for the characteristic
    lambda: |-
      if(x.size() == 12 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0x0F && x[3] == 0x08) {
        // Check if the data is at least 11 bytes and starts with AA 55 0F 08 01
        // Bytes 1-5: AA-55-0F-08-01 (where the 4th byte is the number of bytes following it, e.g., 8)
        // 6th byte (index 5) O2 saturation
        // 7th byte (index 6) Pulse
        // 9th byte (index 8) Perfusion index (?)
        // See: https://github.com/sza2/viatom_pc60fw
        ESP_LOGW("pulseox", "SpO2: %d\tPulse %d\tPI: %d", x[5], x[6], x[8]);
        if(x[5] != 0) {  // SpO2
          id(spo2_current) = x[5];
          if(id(record_to_ha)) id(spo2).publish_state(id(spo2_current));
        }
        if(x[6] != 0) {
          id(pulse_current) = x[6];  // Pulse
          if(id(record_to_ha)) id(pulse).publish_state(id(pulse_current));

          id(perf_index_current) = x[8];  // Perfusion Index
          if(id(record_to_ha)) id(perfindex).publish_state(id(perf_index_current)/10.0);
        }
      }else if (x.size() == 7 && x[0] == 0xAA && x[1] == 0x55 && x[2] == 0xF0 && x[3] == 0x3 && x[4] == 0x3) {
        // 6th byte (index 5) Battery level 0-3
        id(battery_current) = x[5];
        if(id(record_to_ha)) id(battery).publish_state(x[5]);  // Publish raw 0-3 value

        unsigned long uptime = millis()/1000;
        ESP_LOGW("pulsox", "Uptime: %02d:%02d:%02d Total Read: %02d:%02d:%02d  Current Read: %02d:%02d:%02d  Battery: %d",
          uptime / 3600, (uptime % 3600) / 60, uptime % 60,
          id(connect_total) / 3600, (id(connect_total) % 3600) / 60, id(connect_total) % 60,
          id(connect_length) / 3600, (id(connect_length) % 3600) / 60, id(connect_length) % 60,
          id(battery_current));
      }
      return {};  // This sensor doesn't report its own state

  - platform: template
    id: spo2
    name: "SpO2"
    icon: 'mdi:lung'
    unit_of_measurement: '%'
    state_class: measurement
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency
    expire_after: ${expire_after}

  - platform: template
    id: pulse
    name: "Pulse"
    icon: 'mdi:heart-pulse'
    unit_of_measurement: 'bpm'
    state_class: measurement
    accuracy_decimals: 0
    filters:
      - delta: 0.5  # Only publish if the value changes by at least 0.5
      - throttle: ${update_interval}  # Limit update frequency
    expire_after: ${expire_after}

  - platform: template
    id: perfindex
    name: "Perf Index"  # Perfusion Index
    icon: 'mdi:waves'
    unit_of_measurement: '%'
    state_class: measurement
    accuracy_decimals: 1
    filters:
      - delta: 0.05  # Only publish if the value changes by at least 0.05
      - throttle: ${update_interval}  # Limit update frequency
    expire_after: ${expire_after}

  - platform: template
    id: battery
    name: "Battery Level"
    icon: 'mdi:battery'
    entity_category: diagnostic
    disabled_by_default: true   # Change to false if you want to record entity in HA
    lambda: |-
      if (id(battery_current) == 255) return {};
      return id(battery_current);  // Returns raw battery state: 0, 1, 2, or 3 (equivalent roughly to 100%, 66%, 33%, 0%)
    unit_of_measurement: ""
    accuracy_decimals: 0
    expire_after: 120s

switch:
  - platform: template
    name: "Record to HA"
    id: record
    icon: "mdi:record-rec"
    entity_category: config
    disabled_by_default: true   # Change to false if you want to record entity in HA
    optimistic: true
    lambda: |-
      return id(record_to_ha);
    turn_on_action:
      - globals.set:
          id: record_to_ha
          value: 'true'
      - logger.log: "Recording to HA enabled"
    turn_off_action:
      - globals.set:
          id: record_to_ha
          value: 'false'
      - logger.log: "Recording to HA disabled"

###############################################################################
#### Code for tdisplay s7789 240x135 graphical display follows
spi:
  clk_pin: GPIO18
  mosi_pin: GPIO19

font:
  - file: "gfonts://Roboto"
    id: smallfont
    size: 16

  - file: "gfonts://Roboto"
    id: medfont
    size: 25

  - file: "gfonts://Roboto"
    id: bigfont
    size: 45
    glyphs: ["%0123456789 -"]

  - file: "/config/esphome/fonts/MaterialIcons-Regular.ttf"  # Note fixed size font ~24pt
    id: materialfont
    glyphs: ["\uE87D\uE1A4\uEBD4\uEBDD\uE19C"]  # Heart, Battery Full, Battery 5 Bar, Battery 3 Bar, Battery Alert

color:
  - id: red
    red: 100%
    green: 3%
    blue: 5%

  - id: green
    red: 0%
    green: 100%
    blue: 0%

  - id: blue
    red: 0%
    green: 0%
    blue: 100%

  - id: yellow
    red: 100%
    green: 100%
    blue: 0%

  - id: orange
    red: 100%
    green: 65%
    blue: 0%

  - id: magenta
    red: 100%
    green: 0%
    blue: 100%

  - id: gray
    red: 50%
    green: 50%
    blue: 50%

output:
  - platform: gpio
    id: backlight
    pin: GPIO4

display:
  - platform: ili9xxx  # st7789v
    model: ST7789V  # TTGO_TDisplay_135x240
    id: tft_display
    cs_pin: GPIO5
    dc_pin: GPIO16
    reset_pin: GPIO23
    dimensions:
      height: 240
      width: 135
      offset_height: 40
      offset_width: 52
    invert_colors: true  # Required or the colors are all inverted, and Black screen is White
    auto_clear_enabled: false
    rotation: 270
    color_palette: 8BIT  # Use 8-bit color depth to reduce memory usage
    update_interval: ${update_interval}
    lambda: |-
      #ifdef USE_ARDUINO
        ESP_LOGI("mem", "Heap Before: Free: %u", ESP.getFreeHeap());
      #else
        ESP_LOGI("mem", "Heap After: Free: %u", heap_caps_get_free_size(MALLOC_CAP_8BIT));
      #endif

      static bool first_draw = true;
      if(first_draw) {  // Setup backlight & Print static text headings
          it.fill(Color::BLACK);  // Blank screen
          it.print(0, 5, id(smallfont), id(blue), "Total Read");
          it.print(110, 5, id(smallfont), id(blue), "Current Read");
          it.print(0, 55, id(smallfont), id(blue), "SpO2");
          it.print(110, 55, id(smallfont), id(blue), "Pulse");
          it.print(200, 55, id(smallfont), id(blue), "PI %");
          id(backlight).turn_on();
      }

      // Print connect times
      static int connect_length_prev = 0;
      if (id(pc_60fw).connected() || first_draw) {  // Write times if connected or first draw
        if(!first_draw) {  // Update connection length
          id(connect_length) = (millis() / 1000) - id(connect_start);
          id(connect_total) = id(connect_oldtotal) + id(connect_length);
        }
        if (id(connect_length) != connect_length_prev) {
          it.filled_rectangle(0, 20, 220, 25+1, Color::BLACK);  // Clear time row
          it.printf(0, 20, id(medfont), id(orange), "%02d:%02d:%02d",
            (id(connect_total) / 3600)%100, (id(connect_total) % 3600) / 60, id(connect_total) % 60);
          it.printf(110, 20, id(medfont), id(orange), "%02d:%02d:%02d",
            (id(connect_length) / 3600)%100, (id(connect_length) % 3600) / 60, id(connect_length) % 60);
        }
        connect_length_prev = id(connect_length);
      }

      // Print Battery usage
      static uint8_t battery_prev = id(battery_current);
      if(id(battery_current) != battery_prev) {
        if(id(battery_current) == 255)
          it.filled_rectangle(218, 26, 22, 24+1, Color::BLACK);  // Clear Battery guage
        else if(id(battery_current) == 3)
          it.print(218, 26, id(materialfont), id(green), "\uE1A4");  // Battery Full
        else if(id(battery_current) == 2)
          it.print(218, 26, id(materialfont), id(green), "\uEBD4");  // Battery 5-Bar
        else if(id(battery_current) == 1)
          it.print(218, 26, id(materialfont), id(yellow), "\uEBDD");  // Battery 3-Bar
        else
          it.print(218, 26, id(materialfont), id(red), "\uE19C");  // Battery Alert

        battery_prev = id(battery_current);
      }

      // Print SpO2
      Color color;
      static uint8_t spo2_prev = 255;
      if(id(spo2_current) != spo2_prev) {
        it.filled_rectangle(0, 70, 110, 45+1, Color::BLACK);  // Clear SpO2
        if(id(spo2_current)) {
            if(id(spo2_current) >= ${sat_lo}) color = id(green);
            else if(id(spo2_current) >= ${sat_vlo}) color = id(yellow);
            else color = id(red);

            it.printf(0, 70, id(bigfont), color, "%d%%", id(spo2_current));
        } else
            it.print(0, 70, id(bigfont), id(gray), "---%");

        spo2_prev = id(spo2_current);
      }

      // Print Pulse
      static uint8_t pulse_prev = 255;
      if(id(pulse_current) != pulse_prev) {
        it.filled_rectangle(110, 70, 84, 45+1, Color::BLACK);  // Clear Pulse
        if(id(pulse_current)) {
          if(id(pulse_current) > ${pulse_vhi}) color = id(red);
          else if(id(pulse_current) > ${pulse_hi}) color = id(yellow);
          else if(id(pulse_current) >= ${pulse_lo} ) color = id(green);
          else if(id(pulse_current) >= ${pulse_vlo} ) color = id(yellow);
          else color = id(red);

          it.printf(110, 70, id(bigfont), color, "%d", id(pulse_current));
        } else
          it.print(110, 70, id(bigfont), id(gray), "---");
        pulse_prev = id(pulse_current);
      }

      // Print Perfusion Index
      static uint8_t perf_index_prev = 255;
      if(id(perf_index_current) != perf_index_prev) {
        it.filled_rectangle(194, 78, 46, 25+1, Color::BLACK);  // Clear Perfusion Index
        if(id(perf_index_current) != 255) {
          if(id(perf_index_current) > ${perf_index_med}) color = id(green);
          else if(id(perf_index_current) > ${perf_index_lo}) color = id(orange);
          else color = id(red);
          it.printf(194, 78, id(medfont),  color, "%4.1f", id(perf_index_current)/10.0);
        } else
          it.print(194, 78, id(medfont),  id(gray), "---");
        perf_index_prev = id(perf_index_current);
      }

      // Print HA Connect status
      static bool ha_connected_prev = !id(ha_connected);
      if(id(ha_connected) != ha_connected_prev) {
          id(tft_display).filled_rectangle(0, 120, 110, 15, Color::BLACK);  // Clear display area
          id(tft_display).print(0, 120, id(smallfont), id(magenta), id(ha_connected) ? "HA ALIVE" : "HA DROP");
          ha_connected_prev = id(ha_connected);
      }

      // Print HA Recording status
      static uint8_t record_state = 255;  // 0= NO RECORD; 1=RECORD; 2=RECORDING
      if(id(ha_connected) && id(pc_60fw).connected() && id(record_to_ha)) {
        if(record_state != 2) {
          it.filled_rectangle(110, 120, 105, 15, Color::BLACK);
          it.print(110, 120, id(smallfont), id(red), "RECORDING");
        record_state = 2;
        }
      }else if(id(record_to_ha) != record_state) {
          it.filled_rectangle(110, 120, 105, 15, Color::BLACK);
          it.print(110, 120, id(smallfont), id(magenta), id(record_to_ha) ? "RECORD" : "NO RECORD");
          record_state = id(record_to_ha);
      }

      // Print blinking "heartbeat" to show device is alive
      static bool blink = true;
      blink = not(blink);  // Toggle every other display
      if(blink)
        it.print(215, 117, id(materialfont), id(pc_60fw).connected() ? id(red) : id(blue), "\uE87D");
      else
        it.filled_rectangle(215, 117, 24+1, 24+1, Color::BLACK);

      first_draw = false;

      #ifdef USE_ARDUINO
        ESP_LOGI("mem", "Heap After: Free: %u, Min: %u",
                ESP.getFreeHeap(),
                ESP.getMinFreeHeap());
      #else
        ESP_LOGI("mem", "Heap After: Free: %u, Min: %u",
                heap_caps_get_free_size(MALLOC_CAP_8BIT),
                heap_caps_get_minimum_free_size(MALLOC_CAP_8BIT));
      #endif