The following config.yaml interfaces to a bluetooth wireless PC-60FW Pulse Oximeter (I bought mine for about $16 on Aliexpress)
The config:
- Works with a standard ESP32-devkit device
- Creates 2 new sensor entities:
- sensor.pc_60fw_o2sat
- sensor.pc_60fw_pulse
- Displays the O2Sat & Pulse (plus total connected time and current connected time) on a 2-line i2C 1602 display
- The sampling and display update time is set to 1-second but it can be changed via the substitution variable
update_interval
- Short press on the
boot
button toggles recording to HA
(but still updates the display) - 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());