[bq76972] Add BQ76972 BMS chip monitoring component (#17720)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Mathieu
2026-10-12 16:43:51 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston J. Nick Koston
parent f829447b2e
commit 8acf885241
7 changed files with 526 additions and 0 deletions
+1
View File
@@ -106,6 +106,7 @@ esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bq25186/* @Rapsssito
esphome/components/bq27220/* @rggammon
esphome/components/bq76972/* @limpkin
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
+111
View File
@@ -0,0 +1,111 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_ID,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_VOLT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["sensor"]
CODEOWNERS = ["@limpkin"]
DEPENDENCIES = ["i2c"]
DOMAIN = "bq76972"
MULTI_CONF = True
bq76972_ns = cg.esphome_ns.namespace("bq76972")
BQ76972Component = bq76972_ns.class_(
"BQ76972Component", cg.PollingComponent, i2c.I2CDevice
)
CONF_REG_DISABLED = "reg_disabled"
CONF_CRC_ENABLED = "crc_enabled"
CONF_STACK_VOLTAGE = "stack_voltage"
# Ordered as the chip reports them, starting at command 0x68
CONF_TEMPERATURES = [
"internal_temperature",
"cfetoff_temperature",
"dfetoff_temperature",
"alert_temperature",
"ts1_temperature",
"ts2_temperature",
"ts3_temperature",
"hdq_temperature",
"dchg_temperature",
"ddsg_temperature",
]
CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)]
voltage_sensor_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
)
temp_sensor_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BQ76972Component),
cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean,
cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean,
cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema,
**{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES},
**{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES},
}
)
.extend(i2c.i2c_device_schema(0x08))
.extend(cv.polling_component_schema("10s"))
)
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)
cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED]))
cg.add(var.set_reg_disable(config[CONF_REG_DISABLED]))
sensors = sensor.sub_sensors(config)
await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor)
for idx, key in enumerate(CONF_TEMPERATURES):
if (conf := config.get(key)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(var.set_temperature_sensor(sens, idx))
for idx, key in enumerate(CONF_CELL_VOLTAGES):
if (conf := config.get(key)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(var.set_cell_sensor(sens, idx))
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(BQ76972Component),
cv.Required(CONF_ADDRESS): cv.templatable(cv.i2c_address),
},
key=CONF_ADDRESS,
)
automation.register_apply_action(
"bq76972.program_i2c_address",
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA,
automation.ApplyField(CONF_ADDRESS, "program_i2c_address", cg.uint8),
)
+298
View File
@@ -0,0 +1,298 @@
#include "bq76972.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bq76972 {
ESPHOME_LOG_TAG(TAG, "bq76972");
static constexpr uint8_t BQ76972_REG_SUBCOMMAND = 0x3E; // 0x3E low byte, 0x3F high byte
static constexpr uint8_t BQ76972_REG_TRANSFER_BUFFER = 0x40;
static constexpr uint8_t BQ76972_REG_CHECKSUM = 0x60; // 0x60 checksum, 0x61 length
static constexpr uint8_t BQ76972_CMD_BAT_STATUS = 0x12;
static constexpr uint8_t BQ76972_CMD_CELL_START = 0x14;
static constexpr uint8_t BQ76972_CMD_STACK_VOLTAGE = 0x34;
static constexpr uint8_t BQ76972_CMD_TEMP_START = 0x68;
static constexpr uint16_t BQ76972_SUBCMD_ENTER_CFG_UPDATE = 0x0090;
static constexpr uint16_t BQ76972_SUBCMD_EXIT_CFG_UPDATE = 0x0092;
static constexpr uint16_t BQ76972_MEM_REG12_CONFIG = 0x9236;
static constexpr uint16_t BQ76972_MEM_REG0_CONFIG = 0x9237;
static constexpr uint16_t BQ76972_MEM_I2C_ADDRESS = 0x923A;
static constexpr uint16_t BQ76972_MEM_SWAP_COMM_MODE = 0x29BC;
// Pin config of temperature reading N (1 = CFETOFF ... 9 = DDSG) is at this base + N
static constexpr uint16_t BQ76972_MEM_TEMP_PIN_CONFIG_BASE = 0x92F9;
static constexpr size_t BQ76972_TS1_INDEX = 4; // a thermistor input by default
static constexpr uint8_t BQ76972_PIN_CONFIG_THERMISTOR = 0x07;
// 16 words with a CRC byte after every data byte
static constexpr size_t BQ76972_MAX_TRANSFER = 64;
static constexpr uint8_t BQ76972_MAX_POLLS = 50;
static float raw_to_celsius(uint16_t raw) { return static_cast<int16_t>(raw) * 0.1f - 273.15f; }
bool BQ76972Component::read_block_(uint8_t start_register, uint8_t *data, size_t len) {
if (!this->crc_mode_)
return this->read_bytes(start_register, data, len);
// With CRC enabled, every data byte is followed by a CRC byte
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
if (len == 0 || len * 2 > buffer.size())
return false;
if (!this->read_bytes(start_register, buffer.data(), len * 2))
return false;
// The first CRC also covers the write address, register and read address
const uint8_t crc0_input[4] = {static_cast<uint8_t>(this->address_ << 1), start_register,
static_cast<uint8_t>((this->address_ << 1) | 1), buffer[0]};
if (crc8(crc0_input, 4, 0x00, 0x07, true) != buffer[1]) {
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte 0", start_register);
return false;
}
data[0] = buffer[0];
for (size_t i = 1; i < len; ++i) {
if (crc8(&buffer[i * 2], 1, 0x00, 0x07, true) != buffer[i * 2 + 1]) {
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte %u", start_register, static_cast<unsigned>(i));
return false;
}
data[i] = buffer[i * 2];
}
return true;
}
bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, size_t len) {
if (!this->crc_mode_)
return this->write_bytes(start_register, data, len);
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
if (len == 0 || len * 2 > buffer.size())
return false;
// The first CRC also covers the write address and register
const uint8_t crc0_input[3] = {static_cast<uint8_t>(this->address_ << 1), start_register, data[0]};
buffer[0] = data[0];
buffer[1] = crc8(crc0_input, 3, 0x00, 0x07, true);
for (size_t i = 1; i < len; ++i) {
buffer[i * 2] = data[i];
buffer[i * 2 + 1] = crc8(&data[i], 1, 0x00, 0x07, true);
}
return this->write_bytes(start_register, buffer.data(), len * 2);
}
bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) {
std::array<uint8_t, BQ76972_MAX_TRANSFER / 2> bytes;
if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2))
return false;
for (size_t i = 0; i < num_words; i++)
data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]);
return true;
}
bool BQ76972Component::send_subcommand_(uint16_t subcommand) {
const uint8_t bytes[2] = {static_cast<uint8_t>(subcommand & 0xFF), static_cast<uint8_t>(subcommand >> 8)};
return this->write_block_(BQ76972_REG_SUBCOMMAND, bytes, 2);
}
bool BQ76972Component::wait_for_subcommand_() {
uint8_t bytes[2];
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
if (!this->read_block_(BQ76972_REG_SUBCOMMAND, bytes, 2))
return false;
if (bytes[0] != 0xFF && bytes[1] != 0xFF)
return true;
delay(1);
}
return false;
}
bool BQ76972Component::wait_for_cfgupdate_() {
uint16_t status;
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
if (!this->read_words_(BQ76972_CMD_BAT_STATUS, &status, 1))
return false;
if (status & 0x0001)
return true;
delay(1);
}
return false;
}
bool BQ76972Component::read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len) {
return this->send_subcommand_(subcommand) && this->wait_for_subcommand_() &&
this->read_block_(BQ76972_REG_TRANSFER_BUFFER, data, len);
}
bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len) {
if (!this->send_subcommand_(subcommand))
return false;
if (len == 0)
return true;
if (!this->write_block_(BQ76972_REG_TRANSFER_BUFFER, data, len))
return false;
// Inverted 8-bit sum of 0x3E, 0x3F and the data; length counts 0x3E, 0x3F, 0x60, 0x61 and the data
uint8_t sum = static_cast<uint8_t>(subcommand & 0xFF) + static_cast<uint8_t>(subcommand >> 8);
for (size_t i = 0; i < len; ++i)
sum += data[i];
const uint8_t tail[2] = {static_cast<uint8_t>(~sum), static_cast<uint8_t>(4 + len)};
return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2);
}
bool BQ76972Component::enter_cfg_update_() {
if (!this->write_subcommand_(BQ76972_SUBCMD_ENTER_CFG_UPDATE, nullptr, 0))
return false;
delay(10);
return this->wait_for_cfgupdate_();
}
bool BQ76972Component::exit_cfg_update_() {
return this->write_subcommand_(BQ76972_SUBCMD_EXIT_CFG_UPDATE, nullptr, 0);
}
bool BQ76972Component::regs_disabled_(bool &out) {
uint8_t reg12;
uint8_t reg0;
if (!this->read_subcommand_(BQ76972_MEM_REG12_CONFIG, &reg12, 1) ||
!this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, &reg0, 1))
return false;
out = reg12 == 0 && (reg0 & 0x01) == 0;
return true;
}
const LogString *BQ76972Component::configure_() {
uint16_t internal_temp;
if (!this->read_words_(BQ76972_CMD_TEMP_START, &internal_temp, 1))
return LOG_STR(ESP_LOG_MSG_COMM_FAIL);
// Only write the regulator config when it differs, see
// https://e2e.ti.com/support/power-management-group/power-management/f/power-management-forum/1583718/bq76972-undocumented-4-minute-config_update-auto-exit-timeout
bool regs_disabled = true;
if (this->reg_disable_ && !this->regs_disabled_(regs_disabled))
return LOG_STR("Reading regulator config failed");
const bool write_regs = !regs_disabled;
bool write_thermistors = false;
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr)
write_thermistors = true;
}
if (write_regs || write_thermistors) {
if (!this->enter_cfg_update_())
return LOG_STR("Entering config update mode failed");
const LogString *error = this->write_config_(write_regs);
// The chip stops measuring in config update mode, so leave it even after a failed write
if (!this->exit_cfg_update_() && error == nullptr)
error = LOG_STR("Exiting config update mode failed");
if (error != nullptr)
return error;
if (write_regs && (!this->regs_disabled_(regs_disabled) || !regs_disabled))
return LOG_STR("Disabling regulators failed");
}
ESP_LOGD(TAG, "Internal temperature: %.1f°C", raw_to_celsius(internal_temp));
return nullptr;
}
const LogString *BQ76972Component::write_config_(bool write_regs) {
if (write_regs) {
const uint8_t zero = 0;
if (!this->write_subcommand_(BQ76972_MEM_REG12_CONFIG, &zero, 1) ||
!this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1))
return LOG_STR("Writing regulator config failed");
}
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr)
continue;
if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1))
return LOG_STR("Writing thermistor pin config failed");
}
return nullptr;
}
void BQ76972Component::setup() {
if (const LogString *error = this->configure_(); error != nullptr)
this->mark_failed(error);
}
void BQ76972Component::dump_config() {
ESP_LOGCONFIG(TAG, "BQ76972:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
ESP_LOGCONFIG(TAG,
" CRC Mode: %s\n"
" Regulators disabled: %s",
this->crc_mode_ ? LOG_STR_LITERAL("Enabled") : LOG_STR_LITERAL("Disabled"), YESNO(this->reg_disable_));
}
void BQ76972Component::program_i2c_address(uint8_t address) {
if (this->is_failed())
return;
const uint8_t shifted = address << 1;
if (!this->enter_cfg_update_()) {
this->status_set_warning(LOG_STR("Programming I2C address failed"));
return;
}
// The new address only takes effect with the swap, so after a failed write the chip still answers at the
// old one and must still be taken out of config update mode
const bool written = this->write_subcommand_(BQ76972_MEM_I2C_ADDRESS, &shifted, 1) &&
this->write_subcommand_(BQ76972_MEM_SWAP_COMM_MODE, nullptr, 0);
if (written) {
delay(10);
this->set_i2c_address(address);
}
if (!this->exit_cfg_update_() || !written) {
this->status_set_warning(LOG_STR("Programming I2C address failed"));
return;
}
ESP_LOGI(TAG, "I2C address set to 0x%02X", address);
}
template<size_t N> static bool any_set(const std::array<sensor::Sensor *, N> &sensors) {
for (auto *sensor : sensors) {
if (sensor != nullptr)
return true;
}
return false;
}
void BQ76972Component::update() {
// Each group publishes as soon as its read succeeds, so one failed read does not hold back the others
bool ok = true;
uint16_t words[BQ76972_NUM_CELLS];
if (this->stack_voltage_sensor_ != nullptr) {
if (this->read_words_(BQ76972_CMD_STACK_VOLTAGE, words, 1)) {
if (words[0] != 0)
this->stack_voltage_sensor_->publish_state(words[0] * 0.01f); // 10 mV units
} else {
ok = false;
}
}
if (any_set(this->cell_sensors_)) {
if (this->read_words_(BQ76972_CMD_CELL_START, words, BQ76972_NUM_CELLS)) {
for (size_t i = 0; i < BQ76972_NUM_CELLS; i++) {
if (this->cell_sensors_[i] != nullptr && words[i] != 0)
this->cell_sensors_[i]->publish_state(words[i] * 0.001f); // mV
}
} else {
ok = false;
}
}
if (any_set(this->temperature_sensors_)) {
if (this->read_words_(BQ76972_CMD_TEMP_START, words, BQ76972_NUM_TEMPERATURES)) {
for (size_t i = 0; i < BQ76972_NUM_TEMPERATURES; i++) {
if (this->temperature_sensors_[i] != nullptr)
this->temperature_sensors_[i]->publish_state(raw_to_celsius(words[i]));
}
} else {
ok = false;
}
}
if (ok) {
this->status_clear_warning();
} else {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
}
}
} // namespace esphome::bq76972
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include <array>
namespace esphome::bq76972 {
static constexpr size_t BQ76972_NUM_CELLS = 16;
static constexpr size_t BQ76972_NUM_TEMPERATURES = 10;
class BQ76972Component final : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; }
void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; }
void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; }
void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; }
void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; }
void program_i2c_address(uint8_t address);
protected:
bool read_block_(uint8_t start_register, uint8_t *data, size_t len);
bool write_block_(uint8_t start_register, const uint8_t *data, size_t len);
bool read_words_(uint8_t start_register, uint16_t *data, size_t num_words);
bool send_subcommand_(uint16_t subcommand);
bool read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len);
bool write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len);
bool wait_for_subcommand_();
bool wait_for_cfgupdate_();
bool enter_cfg_update_();
bool exit_cfg_update_();
bool regs_disabled_(bool &out);
const LogString *configure_();
const LogString *write_config_(bool write_regs);
bool reg_disable_{false};
bool crc_mode_{false};
sensor::Sensor *stack_voltage_sensor_{nullptr};
std::array<sensor::Sensor *, BQ76972_NUM_CELLS> cell_sensors_{};
std::array<sensor::Sensor *, BQ76972_NUM_TEMPERATURES> temperature_sensors_{};
};
} // namespace esphome::bq76972
+59
View File
@@ -0,0 +1,59 @@
bq76972:
id: bq76972_id
address: 0x08
crc_enabled: true
reg_disabled: true
update_interval: 10s
stack_voltage:
name: "Pack Total Voltage"
internal_temperature:
name: "Temperature - Internal"
ts1_temperature:
name: "Temperature - Pin TS1"
ts2_temperature:
name: "Temperature - Pin TS2"
ts3_temperature:
name: "Temperature - Pin TS3"
hdq_temperature:
name: "Temperature - Pin HDQ"
cell_1_voltage:
name: "Cell 1 Voltage"
cell_2_voltage:
name: "Cell 2 Voltage"
cell_3_voltage:
name: "Cell 3 Voltage"
cell_4_voltage:
name: "Cell 4 Voltage"
cell_5_voltage:
name: "Cell 5 Voltage"
cell_6_voltage:
name: "Cell 6 Voltage"
cell_7_voltage:
name: "Cell 7 Voltage"
cell_8_voltage:
name: "Cell 8 Voltage"
cell_9_voltage:
name: "Cell 9 Voltage"
cell_10_voltage:
name: "Cell 10 Voltage"
cell_11_voltage:
name: "Cell 11 Voltage"
cell_12_voltage:
name: "Cell 12 Voltage"
cell_13_voltage:
name: "Cell 13 Voltage"
cell_14_voltage:
name: "Cell 14 Voltage"
cell_15_voltage:
name: "Cell 15 Voltage"
cell_16_voltage:
name: "Cell 16 Voltage"
button:
- platform: template
name: "Program BQ I2C Address"
on_press:
- bq76972.program_i2c_address:
id: bq76972_id
address: 0x09
- bq76972.program_i2c_address: !lambda "return 0x08;"
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
bq76972: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
bq76972: !include common.yaml