Skip to content
Snowbird Power Systems

Reference build

LiFePO4 Home Assistant sensor

An indoor temperature and humidity node on an ESP32 Feather, one 18650 LiFePO4 cell, months per charge, discovered by Home Assistant with no integration work.

Snowbird 18650 LiFePO4
A Snowbird Feather 1S with a plain microcontroller Feather stacked on top of it on black stacking headers, a pouch cell plugged into the lower board.

What it does

A temperature and humidity sensor for a room that has no outlet near where the reading matters: a crawlspace, a greenhouse, a garage freezer. The host is an ESP32-S3 Feather. It wakes every five minutes, reads an SHT45, and hands the reading to the Snowbird 18650 over I2C. Snowbird's own Bluetooth radio broadcasts it as a BTHome v2 advertisement, so Home Assistant picks up the sensor, the state of charge, the cell voltage and the charging state as soon as the node is in range of a Bluetooth proxy. There is no integration to install and no Wi-Fi credentials to manage.

The ESP32 never turns its own radio on. That is what makes the cell last months.

Parts

Part Role Example
Snowbird 18650 Charger, BMS, gauge, 3.3 V rail, BTHome advertising snowbird-18650
ESP32-S3 Feather Application MCU Feather ESP32-S3, no PSRAM needed
LiFePO4 18650, 1500 mAh Energy store, in the on-board holder IFR18650, flat top, unprotected
Temperature and humidity sensor The reading SHT45 on a Qwiic breakout
Bluetooth proxy Receives the advertisements An ESP32 running ESPHome as a Bluetooth proxy, or a Pi with Home Assistant
Stacking headers Host above Snowbird Feather stacking header set
USB-C cable Top-up every few months Any 5 V USB-C source

Wiring

Snowbird pin Device pin Note
3V Host 3V Through the header; the ESP32 runs from Snowbird's 3.3 V rail
EN jumper (closed) Host EN Ships closed. The host's regulator stays off
SDA, SCL Host SDA, SCL I2C at 0x36
INT (jumper 11) open Not needed; the host polls once per wake
PG (jumper A2) open
CHG_DONE (jumper A3) open Home Assistant shows charging state from the advertisement instead
EN5V (jumper A5) open 5 V rail off
18650 holder IFR18650 cell Positive toward the marked end; the NTC pad sits against the cell wrap
Qwiic 1 SHT45 50 mm cable
USB-C Charger Plug in a few times a year
Host BAT, USB not connected

The chemistry jumper goes to LiFePO4 before the cell goes into the holder. The holder's thermistor pad is the one the charger gates on, so the cell's own temperature, not the room's, decides whether charging is allowed.

Power budget

Load Duty Average current Daily energy
ESP32-S3 deep sleep 99.9 % 9 µA 0.0007 Wh
Wake, read SHT45, write to Snowbird 120 ms every 5 min 20 µA 0.0016 Wh
SHT45 idle 100 % 0.1 µA 0.0000 Wh
Snowbird idle with BLE advertising at 2 s 100 % 42 µA 0.0033 Wh
Total 71 µA 0.0056 Wh

A 1500 mAh LiFePO4 cell holds 4.8 Wh, so the node runs about 850 days between charges on paper. The gauge calls it 14 months after the discharge curve's knee is accounted for, and that is the number the dashboard shows as time-to-empty. Charging from empty takes about two hours at 0.5 C from any USB-C source.

Sketch

#include <Wire.h>
#include <Snowbird.h>
#include <Adafruit_SHT4x.h>
#include <esp_sleep.h>

Snowbird pmic;
Adafruit_SHT4x sht;

constexpr uint64_t SLEEP_US = 5ULL * 60 * 1000000;

void setup() {
  Wire.begin();
  pmic.begin(Wire);

  if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_UNDEFINED) {
    // First boot only: the settings persist in Snowbird across host sleeps.
    pmic.setChemistry(Chem::LiFePO4);
    pmic.setCapacity_mAh(1500);
    pmic.setChargeCurrent_mA(750);           // 0.5 C
    pmic.set5V(false);
    pmic.setAdvertising(Advertise::BTHome, 2000);
    pmic.setBTHomeFields(BTHOME_BATTERY | BTHOME_VOLTAGE | BTHOME_CHARGING
                         | BTHOME_TEMPERATURE | BTHOME_HUMIDITY);
  }

  sht.begin();
  sht.setPrecision(SHT4X_HIGH_PRECISION);
  sht.setHeater(SHT4X_NO_HEATER);

  sensors_event_t hum, temp;
  sht.getEvent(&hum, &temp);

  // Snowbird broadcasts these in the next advertisement, alongside its own
  // battery fields, until the host writes new ones.
  pmic.setBTHomeTemperature(temp.temperature);
  pmic.setBTHomeHumidity(hum.relative_humidity);

  uint32_t f = pmic.faults();
  if (f & (FAULT_UV | FAULT_BMS_TRIP)) {
    // Cell is on its last few percent. Stretch the interval so the
    // gauge's time-to-empty is still honest when someone looks.
    esp_sleep_enable_timer_wakeup(SLEEP_US * 6);
  } else {
    esp_sleep_enable_timer_wakeup(SLEEP_US);
  }

  Wire.end();
  esp_deep_sleep_start();
}

void loop() {}

The MicroPython version is the same five calls:

from snowbird import Snowbird, Chem
from machine import I2C, Pin, deepsleep
import sht4x

i2c = I2C(0, scl=Pin(4), sda=Pin(3))
pmic = Snowbird(i2c)
pmic.set_chemistry(Chem.LIFEPO4)
pmic.set_capacity_mah(1500)
t, h = sht4x.SHT4X(i2c).measurements
pmic.set_bthome_temperature(t)
pmic.set_bthome_humidity(h)
deepsleep(5 * 60 * 1000)

Field notes

Home Assistant found the node forty seconds after the cell went in. It appears as a BTHome device with five entities: temperature, humidity, battery, voltage and a charging binary sensor. The battery entity is Snowbird's state of charge, which on LiFePO4 is a coulomb count and not a voltage guess, so the dashboard shows a straight line down over the months rather than sitting at 100 % for weeks and then collapsing.

The first attempt had the ESP32 doing the Bluetooth itself. It worked and it drew 3 mA average, because the ESP32's radio stack and its wake-up overhead dominate everything. Handing the advertisement to Snowbird's supervisor cut the average to 71 µA. The ESP32 is a sensor reader now, nothing more, and it never has to know the house has Bluetooth.

Put the node where the proxy can hear it. A single ESPHome Bluetooth proxy covers a floor of a wood-framed house; a concrete crawlspace needed its own. The advertisement at 2 s is comfortably frequent for a five-minute sensor; dropping it to 10 s saves another 10 µA if the proxy is close.

One cell in the test ran 203 days from full before the gauge called 10 %, against the 14-month estimate; that node was in a garage that saw minus five in January, and the cell's usable capacity drops with it. The charge window held charging off on the cold mornings when the cable was plugged in, which is what it is for; the yellow LED blinked the temperature hold until the garage warmed up, then charged normally.

Unprotected cells are fine here, and they are what you want: a cell with its own protection board has thresholds set for Li-ion and would never trip on LiFePO4. Snowbird's BMS carries the LiFePO4 thresholds, so the protection follows the chemistry jumper.