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
###############################################################################
# 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());

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
###############################################################################
# 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;