diff --git a/CODEOWNERS b/CODEOWNERS index 075b8113d8..f068a3a221 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py new file mode 100644 index 0000000000..5ccf043c25 --- /dev/null +++ b/esphome/components/bq76972/__init__.py @@ -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), +) diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp new file mode 100644 index 0000000000..ea229307d5 --- /dev/null +++ b/esphome/components/bq76972/bq76972.cpp @@ -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(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 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(this->address_ << 1), start_register, + static_cast((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(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 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(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 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(subcommand & 0xFF), static_cast(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(subcommand & 0xFF) + static_cast(subcommand >> 8); + for (size_t i = 0; i < len; ++i) + sum += data[i]; + const uint8_t tail[2] = {static_cast(~sum), static_cast(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, ®12, 1) || + !this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, ®0, 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 static bool any_set(const std::array &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 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h new file mode 100644 index 0000000000..d27a4ce96f --- /dev/null +++ b/esphome/components/bq76972/bq76972.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +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 cell_sensors_{}; + std::array temperature_sensors_{}; +}; + +} // namespace esphome::bq76972 diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml new file mode 100644 index 0000000000..42a9f633af --- /dev/null +++ b/tests/components/bq76972/common.yaml @@ -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;" diff --git a/tests/components/bq76972/test.esp32-idf.yaml b/tests/components/bq76972/test.esp32-idf.yaml new file mode 100644 index 0000000000..b0842c47a5 --- /dev/null +++ b/tests/components/bq76972/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq76972: !include common.yaml diff --git a/tests/components/bq76972/test.esp8266-ard.yaml b/tests/components/bq76972/test.esp8266-ard.yaml new file mode 100644 index 0000000000..504777ab5b --- /dev/null +++ b/tests/components/bq76972/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq76972: !include common.yaml