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