From b88eafcd00070de1c894b764430f21f3a860743a Mon Sep 17 00:00:00 2001 From: Ryan Gammon Date: Mon, 12 Oct 2026 15:05:34 -0700 Subject: [PATCH] [bq27220] Add BQ27220 battery fuel gauge component (#17702) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq27220/__init__.py | 2 + esphome/components/bq27220/bq27220.cpp | 93 ++++++++++++++ esphome/components/bq27220/bq27220.h | 44 +++++++ esphome/components/bq27220/sensor.py | 123 +++++++++++++++++++ tests/components/bq27220/common.yaml | 19 +++ tests/components/bq27220/test.esp32-idf.yaml | 3 + 7 files changed, 285 insertions(+) create mode 100644 esphome/components/bq27220/__init__.py create mode 100644 esphome/components/bq27220/bq27220.cpp create mode 100644 esphome/components/bq27220/bq27220.h create mode 100644 esphome/components/bq27220/sensor.py create mode 100644 tests/components/bq27220/common.yaml create mode 100644 tests/components/bq27220/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2b77a16db8..87a89a14f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -101,6 +101,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core diff --git a/esphome/components/bq27220/__init__.py b/esphome/components/bq27220/__init__.py new file mode 100644 index 0000000000..2a5d7bc1d7 --- /dev/null +++ b/esphome/components/bq27220/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@rggammon"] +DOMAIN = "bq27220" diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp new file mode 100644 index 0000000000..d2f7961e02 --- /dev/null +++ b/esphome/components/bq27220/bq27220.cpp @@ -0,0 +1,93 @@ +#include "bq27220.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bq27220 { + +ESPHOME_LOG_TAG(TAG, "bq27220"); + +// Standard command registers (16-bit, little-endian), SLUUBD4A +static constexpr uint8_t REG_CONTROL = 0x00; +static constexpr uint8_t REG_TEMPERATURE = 0x06; // 0.1 K +static constexpr uint8_t REG_VOLTAGE = 0x08; // mV +static constexpr uint8_t REG_CURRENT = 0x0C; // mA, signed +static constexpr uint8_t REG_REMAINING_CAPACITY = 0x10; // mAh +static constexpr uint8_t REG_FULL_CHARGE_CAPACITY = 0x12; // mAh +static constexpr uint8_t REG_TIME_TO_EMPTY = 0x16; // min, 0xFFFF when not discharging +static constexpr uint8_t REG_STATE_OF_CHARGE = 0x2C; // % +static constexpr uint8_t REG_STATE_OF_HEALTH = 0x2E; // % +// The DEVICE_NUMBER subcommand (0x0001, SLUUBD4A §2.2.2) answers through the MAC data buffer +static constexpr uint8_t REG_MAC_DATA = 0x40; +static constexpr uint16_t DEVICE_NUMBER = 0x0220; +static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF; + +bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) { + uint8_t data[2]; + if (this->read_register(reg, data, 2) != i2c::ERROR_OK) { + return false; + } + value = encode_uint16(data[1], data[0]); + return true; +} + +void BQ27220Component::setup() { + const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian + if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to communicate with BQ27220")); + return; + } + delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read + uint16_t device_number = 0; + if (!this->read_word_(REG_MAC_DATA, device_number)) { + this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); + return; + } + if (device_number != DEVICE_NUMBER) { + ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER); + this->mark_failed(); + } +} + +void BQ27220Component::update() { + bool ok = true; + this->publish_(this->voltage_sensor_, REG_VOLTAGE, ok, [](uint16_t raw) { return raw * 0.001f; }); + this->publish_(this->current_sensor_, REG_CURRENT, ok, + [](uint16_t raw) { return static_cast(raw) * 0.001f; }); + this->publish_(this->battery_level_sensor_, REG_STATE_OF_CHARGE, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->temperature_sensor_, REG_TEMPERATURE, ok, [](uint16_t raw) { return raw * 0.1f - 273.15f; }); + this->publish_(this->remaining_capacity_sensor_, REG_REMAINING_CAPACITY, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->full_charge_capacity_sensor_, REG_FULL_CHARGE_CAPACITY, ok, + [](uint16_t raw) { return float(raw); }); + this->publish_(this->time_to_empty_sensor_, REG_TIME_TO_EMPTY, ok, + [](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); }); + this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); }); + + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220")); + } +} + +void BQ27220Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ27220 Battery Fuel Gauge:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Remaining Capacity", this->remaining_capacity_sensor_); + LOG_SENSOR(" ", "Full Charge Capacity", this->full_charge_capacity_sensor_); + LOG_SENSOR(" ", "Time To Empty", this->time_to_empty_sensor_); + LOG_SENSOR(" ", "State Of Health", this->state_of_health_sensor_); +} + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h new file mode 100644 index 0000000000..d88b76a20f --- /dev/null +++ b/esphome/components/bq27220/bq27220.h @@ -0,0 +1,44 @@ +#pragma once + +// Datasheet: https://www.ti.com/lit/ds/symlink/bq27220.pdf +// Technical Reference Manual: https://www.ti.com/lit/ug/sluubd4a/sluubd4a.pdf + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bq27220 { + +class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(battery_level) + SUB_SENSOR(temperature) + SUB_SENSOR(remaining_capacity) + SUB_SENSOR(full_charge_capacity) + SUB_SENSOR(time_to_empty) + SUB_SENSOR(state_of_health) + + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool read_word_(uint8_t reg, uint16_t &value); + /// Read a standard command register and publish it through the sensor, if one is configured. + /// A failed read publishes NAN and clears `ok`. + template void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) { + if (sensor == nullptr) + return; + uint16_t raw; + if (!this->read_word_(reg, raw)) { + ok = false; + sensor->publish_state(NAN); + return; + } + sensor->publish_state(convert(raw)); + } +}; + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py new file mode 100644 index 0000000000..a3fcec2243 --- /dev/null +++ b/esphome/components/bq27220/sensor.py @@ -0,0 +1,123 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_MILLIAMPERE_HOUR +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CURRENT, + CONF_ID, + CONF_TEMPERATURE, + CONF_VOLTAGE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_BATTERY, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_MINUTE, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +bq27220_ns = cg.esphome_ns.namespace("bq27220") +BQ27220Component = bq27220_ns.class_( + "BQ27220Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REMAINING_CAPACITY = "remaining_capacity" +CONF_FULL_CHARGE_CAPACITY = "full_charge_capacity" +CONF_TIME_TO_EMPTY = "time_to_empty" +CONF_STATE_OF_HEALTH = "state_of_health" + +# Other SLUUBD4A standard commands are intentionally not exposed to keep the +# component focused; they can be added later if there is demand: TimeToFull +# (0x18), CycleCount (0x2A), BatteryStatus (0x0A), AveragePower (0x24), +# DesignCapacity (0x3C). + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ27220Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_REMAINING_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_FULL_CHARGE_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIME_TO_EMPTY): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_STATE_OF_HEALTH): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x55)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_REMAINING_CAPACITY, var.set_remaining_capacity_sensor) + await sensors(CONF_FULL_CHARGE_CAPACITY, var.set_full_charge_capacity_sensor) + await sensors(CONF_TIME_TO_EMPTY, var.set_time_to_empty_sensor) + await sensors(CONF_STATE_OF_HEALTH, var.set_state_of_health_sensor) diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml new file mode 100644 index 0000000000..99d1e1f7f8 --- /dev/null +++ b/tests/components/bq27220/common.yaml @@ -0,0 +1,19 @@ +sensor: + - platform: bq27220 + id: bq27220_id + voltage: + name: "Battery Voltage" + current: + name: "Battery Current" + battery_level: + name: "Battery" + temperature: + name: "Battery Temperature" + remaining_capacity: + name: "Battery Remaining" + full_charge_capacity: + name: "Battery Full Capacity" + time_to_empty: + name: "Time to Empty" + state_of_health: + name: "Battery Health" diff --git a/tests/components/bq27220/test.esp32-idf.yaml b/tests/components/bq27220/test.esp32-idf.yaml new file mode 100644 index 0000000000..fe4dd41cef --- /dev/null +++ b/tests/components/bq27220/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq27220: !include common.yaml