[bq27220] Add BQ27220 battery fuel gauge component (#17702)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Ryan Gammon
2026-10-12 12:05:34 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent d7dd25a1bc
commit b88eafcd00
7 changed files with 285 additions and 0 deletions
+1
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@rggammon"]
DOMAIN = "bq27220"
+93
View File
@@ -0,0 +1,93 @@
#include "bq27220.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cmath>
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<int16_t>(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
+44
View File
@@ -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<typename F> 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
+123
View File
@@ -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)
+19
View File
@@ -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"
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
bq27220: !include common.yaml