diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index e57860786f5..03a100d9297 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -5,8 +5,8 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID -from esphome.core import CORE, ID, Lambda +from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG +from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@Rapsssito"] @@ -14,6 +14,7 @@ DEPENDENCIES = ["i2c"] DOMAIN = "bq25186" MULTI_CONF = True +CONF_BQ25186_ID = "bq25186_id" CONF_CONFIGURE_ON_BOOT = "configure_on_boot" CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" CONF_FAST_CHARGE_CURRENT = "fast_charge_current" @@ -43,6 +44,7 @@ CONF_WAKE2_TIMER = "wake2_timer" CONF_ENABLE_PUSH_BUTTON = "enable_push_button" CONF_SYSTEM_REGULATION = "system_regulation" CONF_SYS_MODE = "sys_mode" +CONF_WATCHDOG_15S_ENABLE = "watchdog_15s_enable" CONF_DISABLE_VDPPM = "disable_vdppm" CONF_TS_HOT = "ts_hot" CONF_TS_COLD = "ts_cold" @@ -56,7 +58,26 @@ CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt" CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt" bq25186_ns = cg.esphome_ns.namespace("bq25186") -BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice) +BQ25186Component = bq25186_ns.class_( + "BQ25186Component", cg.PollingComponent, i2c.I2CDevice +) + +BQ25186_CHILD_SCHEMA = cv.Schema( + {cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)} +) +_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT") +_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT") + + +def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_binary_sensor_slot(str(hub)) + return hub.register_binary_sensor(var) + + +def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_text_sensor_slot(str(hub)) + return hub.register_text_sensor(var) + REG_VBAT_CTRL = 0x03 REG_ICHG_CTRL = 0x04 @@ -69,7 +90,6 @@ REG_SYS_REG = 0x0A REG_TS_CONTROL = 0x0B REG_MASK_ID = 0x0C REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1 -WATCHDOG_DISABLED = 0x03 TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3} VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3} @@ -86,6 +106,22 @@ BATTERY_UVLO_OPTIONS = { VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1} RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1} SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3} +# The I2C watchdog restores the power-on registers when the bus stays quiet for its period +WATCHDOG_OPTIONS = { + "160s_software_reset": 0, + "160s_hardware_reset": 1, + "40s_hardware_reset": 2, + "disabled": 3, +} +WATCHDOG_PERIOD_S = { + "160s_software_reset": 160, + "160s_hardware_reset": 160, + "40s_hardware_reset": 40, +} +# SYS_REG watchdog_15s_enable: a hardware reset when the bus stays quiet for 15 s after VIN connects +WATCHDOG_15S_PERIOD_S = 15 +# The chip boots with the 160 s software reset watchdog, which runs until the configuration is applied +POWER_ON_WATCHDOG_PERIOD_S = 160 LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3} AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3} INPUT_CURRENT_LIMIT_OPTIONS = { @@ -146,8 +182,8 @@ def _on_current_step(amps: float) -> float: # (config key, register, bit shift, validator or enum options, default). -# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are -# driven at runtime, not from configuration. +# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; the PG/GPO +# switch drives them. FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( ( CONF_BATTERY_REGULATION_VOLTAGE, @@ -179,6 +215,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"), (CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False), (CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"), + (CONF_WATCHDOG, REG_IC_CTRL, 0, WATCHDOG_OPTIONS, "disabled"), (CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"), (CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False), (CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"), @@ -195,6 +232,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True), (CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"), (CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"), + (CONF_WATCHDOG_15S_ENABLE, REG_SYS_REG, 1, cv.boolean, False), (CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False), (CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"), (CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"), @@ -232,9 +270,6 @@ _ENCODERS: dict[str, Callable[[Any], int]] = { def pack_registers(config: ConfigType) -> list[int]: """Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes.""" regs = [0] * REGISTER_COUNT - # Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running - # watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling. - regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED for key, register, shift, validator, _ in FIELDS: value = config[key] if isinstance(validator, dict): @@ -259,7 +294,28 @@ def _field_schema(with_defaults: bool) -> cv.Schema: BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) -CONFIG_SCHEMA = ( + +def _check_watchdog(config: ConfigType) -> ConfigType: + """Only the status poll talks to the chip, so it has to come round before any watchdog does.""" + periods = [] + if (period := WATCHDOG_PERIOD_S.get(config[CONF_WATCHDOG])) is not None: + periods.append(period) + if config[CONF_WATCHDOG_15S_ENABLE]: + periods.append(WATCHDOG_15S_PERIOD_S) + if not config.get(CONF_CONFIGURE_ON_BOOT, True): + periods.append(POWER_ON_WATCHDOG_PERIOD_S) + if not periods: + return config + if config[CONF_UPDATE_INTERVAL] >= TimePeriod(seconds=(limit := min(periods))): + raise cv.Invalid( + f"{CONF_UPDATE_INTERVAL} must be shorter than {limit} s, or the " + f"watchdog resets the charger between status polls", + path=[CONF_UPDATE_INTERVAL], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BQ25186Component), @@ -267,7 +323,9 @@ CONFIG_SCHEMA = ( } ) .extend(BQ25186_CONFIG_SCHEMA) - .extend(i2c.i2c_device_schema(0x6A)) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x6A)), + _check_watchdog, ) CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( @@ -305,7 +363,12 @@ async def bq25186_configure_to_code( # Options the action leaves out fall back to the component's configuration path = CORE.config.get_path_for_id(config[CONF_ID])[:-1] parent_config = CORE.config.get_config_for_path(path) - regs = pack_registers({**parent_config, **config}) + merged = {**parent_config, **config} + try: + _check_watchdog(merged) + except cv.Invalid as err: + raise EsphomeError(f"bq25186.apply_configuration: {err.msg}") from err + regs = pack_registers(merged) text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) diff --git a/esphome/components/bq25186/binary_sensor.py b/esphome/components/bq25186/binary_sensor.py new file mode 100644 index 00000000000..fb741950e29 --- /dev/null +++ b/esphome/components/bq25186/binary_sensor.py @@ -0,0 +1,73 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_RUNNING, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186BinarySensor = bq25186_ns.class_( + "BQ25186BinarySensor", binary_sensor.BinarySensor +) + +REG_STAT0 = 0 +REG_STAT1 = 1 +REG_FLAG0 = 2 +# Bit set for each field value that reads as on: a single bit is on when it is 1 +_BIT_SET = 0b10 + +# (key, status register, bit shift, field mask, on-values bitmap, device class) +_FIELDS = ( + ("ts", REG_STAT0, 7, 0x01, _BIT_SET, DEVICE_CLASS_OPENING), + ("charging", REG_STAT0, 5, 0x03, 0b0110, DEVICE_CLASS_BATTERY_CHARGING), + ("charge_done", REG_STAT0, 5, 0x03, 0b1000, cv.UNDEFINED), + ("ilim", REG_STAT0, 4, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vdppm", REG_STAT0, 3, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vindpm", REG_STAT0, 2, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("thermreg", REG_STAT0, 1, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("power_good", REG_STAT0, 0, 0x01, _BIT_SET, DEVICE_CLASS_PLUG), + ("vin_ovp", REG_STAT1, 7, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("battery_uvlo", REG_STAT1, 6, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("safety_timer_flag", REG_STAT1, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake1_flag", REG_STAT1, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake2_flag", REG_STAT1, 0, 0x01, _BIT_SET, cv.UNDEFINED), + ("ts_flag", REG_FLAG0, 7, 0x01, _BIT_SET, cv.UNDEFINED), + ("ilim_flag", REG_FLAG0, 6, 0x01, _BIT_SET, cv.UNDEFINED), + ("vdppm_flag", REG_FLAG0, 5, 0x01, _BIT_SET, cv.UNDEFINED), + ("vindpm_flag", REG_FLAG0, 4, 0x01, _BIT_SET, cv.UNDEFINED), + ("thermreg_flag", REG_FLAG0, 3, 0x01, _BIT_SET, cv.UNDEFINED), + ("vin_ovp_flag", REG_FLAG0, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_uvlo_flag", REG_FLAG0, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_ocp_flag", REG_FLAG0, 0, 0x01, _BIT_SET, cv.UNDEFINED), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): binary_sensor.binary_sensor_schema( + BQ25186BinarySensor, device_class=device_class + ) + for key, *_, device_class in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + for key, reg, shift, mask, match, _ in _FIELDS: + await sensors( + key, partial(register_binary_sensor, hub), reg, shift, mask, match + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index e660ec63d14..edea50d1115 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -1,4 +1,10 @@ #include "bq25186.h" +#ifdef USE_BINARY_SENSOR +#include "bq25186_binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "bq25186_text_sensor.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -7,10 +13,10 @@ namespace esphome::bq25186 { ESPHOME_LOG_TAG(TAG, "bq25186"); -// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode, -// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read +// Bits the configuration owns in each register; the rest are the SYS_REG GPO level, reset +// triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { - 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, }; bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { @@ -31,16 +37,38 @@ bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value return this->write_register_(reg, reg_value); } +uint8_t BQ25186Component::expected_register_(size_t index) const { + uint8_t value = this->regs_[index]; +#ifdef USE_BQ25186_PG_GPO + if (index == 0 && this->pg_gpo_switch_ != nullptr) + value |= BQ25186_VBAT_CTRL_PG_MODE; +#endif + return value; +} + +bool BQ25186Component::config_matches_(const BQ25186StatusRegisters ®s) const { + for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) { + const size_t index = reg - BQ25186_REG_VBAT_CTRL; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]); + if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0) + return false; + } + return true; +} + bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { // Setup rejects an unknown chip; the action must not program it before that check or after it failed if (!this->is_ready() || this->is_failed()) { ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); return false; } + this->regs_ = regs; + this->configured_ = true; for (size_t i = 0; i < regs.size(); i++) { const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); - const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]); + const uint8_t value = this->expected_register_(i); + const bool ok = mask == 0xFF ? this->write_register_(reg, value) : this->update_register_(reg, mask, value); if (!ok) { this->status_set_warning(LOG_STR("Configuration partly applied")); return false; @@ -68,12 +96,80 @@ void BQ25186Component::setup() { // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry if (this->configure_on_boot_) - this->apply_config(this->setup_regs_); + this->apply_config(this->regs_); +#ifdef USE_BQ25186_PG_GPO + if (this->pg_gpo_switch_ != nullptr) { + // The poll below puts the pin in output mode; without a restore mode the switch takes its level from it + auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode(); + if (initial.has_value()) + this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin + } +#endif + // The poller first runs after a random share of the interval; the entities get a state now + this->update(); +} + +#ifdef USE_BQ25186_PG_GPO +// The pin mode and level come from the switch, so every poll puts back whatever the chip lost +void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending) { + if (this->pg_gpo_switch_ == nullptr) + return; + if ((status[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE) == 0 && + !this->update_register_(BQ25186_REG_VBAT_CTRL, BQ25186_VBAT_CTRL_PG_MODE, BQ25186_VBAT_CTRL_PG_MODE)) { + this->status_set_warning(LOG_STR("PG/GPO pin not in output mode")); + pending = true; + } + const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0; + if (!this->pg_gpo_switch_->has_state()) { + this->pg_gpo_switch_->publish_state(level); + } else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) { + this->status_set_warning(LOG_STR("PG/GPO level write failed")); + pending = true; + } +} +#endif + +// Every poll also serves the I2C watchdog, which wants to see traffic within its period +void BQ25186Component::update() { + BQ25186StatusRegisters status; + if (!this->read_bytes(BQ25186_REG_STAT0, status.data(), status.size())) { + this->status_set_warning(LOG_STR("Status read failed")); + return; + } + // Whatever failed is tried again here, so the warning only stays while something is still wrong + bool pending = false; + // A watchdog or hardware reset puts the power-on values back, and a failed write leaves a register + // behind; either way the configuration is written again + if (this->configured_ && !this->config_matches_(status)) { + ESP_LOGW(TAG, "Configuration registers differ from the values applied, writing them again"); + if (this->apply_config(this->regs_)) { + status[BQ25186_REG_VBAT_CTRL] |= BQ25186_VBAT_CTRL_PG_MODE; // part of the write when a switch is configured + } else { + pending = true; + } + } +#ifdef USE_BQ25186_PG_GPO + this->check_pg_gpo_(status, pending); +#endif + if (!pending) + this->status_clear_warning(); +#ifdef BQ25186_BINARY_SENSOR_COUNT + for (auto *sensor : this->binary_sensors_) + sensor->on_status(status); +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + for (auto *sensor : this->text_sensors_) + sensor->on_status(status); +#endif } void BQ25186Component::dump_config() { ESP_LOGCONFIG(TAG, "BQ25186:"); LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +#ifdef USE_BQ25186_PG_GPO + LOG_SWITCH(" ", "PG/GPO", this->pg_gpo_switch_); +#endif } } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h index a6920871475..0a9a45c28b4 100644 --- a/esphome/components/bq25186/bq25186.h +++ b/esphome/components/bq25186/bq25186.h @@ -2,11 +2,19 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#ifdef USE_BQ25186_PG_GPO +#include "esphome/components/switch/switch.h" +#endif #include namespace esphome::bq25186 { +static constexpr uint8_t BQ25186_REG_STAT0 = 0x00; +static constexpr uint8_t BQ25186_REG_STAT1 = 0x01; +static constexpr uint8_t BQ25186_REG_FLAG0 = 0x02; static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03; static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04; static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05; @@ -18,27 +26,70 @@ static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A; static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B; static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C; static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1; +// STAT0 to MASK_ID, read together by every poll: the status for the entities, then the configuration +// registers, which show whether the chip was reset or a write failed +static constexpr size_t BQ25186_STATUS_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_STAT0 + 1; + +// VBAT_CTRL pg_mode: the /PG pin is a general purpose output driven by SYS_REG pg_gpo_level +static constexpr uint8_t BQ25186_VBAT_CTRL_PG_MODE = 0x80; +static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10; // Register values for VBAT_CTRL..MASK_ID, packed by code generation using BQ25186ConfigRegisters = std::array; +using BQ25186StatusRegisters = std::array; -class BQ25186Component : public Component, public i2c::I2CDevice { +class BQ25186BinarySensor; +class BQ25186TextSensor; + +class BQ25186Component : public PollingComponent, public i2c::I2CDevice { public: void setup() override; + void update() override; void dump_config() override; void set_setup_registers(const BQ25186ConfigRegisters ®s) { - this->setup_regs_ = regs; + this->regs_ = regs; this->configure_on_boot_ = true; } bool apply_config(const BQ25186ConfigRegisters ®s); +#ifdef BQ25186_BINARY_SENSOR_COUNT + void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); } +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); } +#endif +#ifdef USE_BQ25186_PG_GPO + /// The /PG/GPO pin is put in general purpose output mode when a switch drives it + void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; } + bool write_pg_gpo_level(bool level) { + return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL, + level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0); + } +#endif + /// Write the masked bits of SHIP_RST + bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); } protected: bool write_register_(uint8_t reg, uint8_t value); + uint8_t expected_register_(size_t index) const; + bool config_matches_(const BQ25186StatusRegisters ®s) const; +#ifdef USE_BQ25186_PG_GPO + void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending); +#endif bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); - BQ25186ConfigRegisters setup_regs_{}; +#ifdef BQ25186_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + StaticVector text_sensors_; +#endif +#ifdef USE_BQ25186_PG_GPO + switch_::Switch *pg_gpo_switch_{nullptr}; +#endif + BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset bool configure_on_boot_{false}; + bool configured_{false}; // regs_ went to the chip at least once }; } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186_binary_sensor.h b/esphome/components/bq25186/bq25186_binary_sensor.h new file mode 100644 index 00000000000..265f34a4459 --- /dev/null +++ b/esphome/components/bq25186/bq25186_binary_sensor.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR + +#include "bq25186.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome::bq25186 { + +/// One field of a status register; `match` has a bit set for each field value that reads as on +class BQ25186BinarySensor final : public binary_sensor::BinarySensor { + public: + BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match) + : reg_(reg), shift_(shift), mask_(mask), match_(match) {} + + void on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_; + this->publish_state((this->match_ >> value) & 1); + } + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t mask_; + const uint8_t match_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BINARY_SENSOR diff --git a/esphome/components/bq25186/bq25186_button.h b/esphome/components/bq25186/bq25186_button.h new file mode 100644 index 00000000000..e088166ac6c --- /dev/null +++ b/esphome/components/bq25186/bq25186_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BUTTON + +#include "bq25186.h" +#include "esphome/components/button/button.h" + +namespace esphome::bq25186 { + +/// Writes one SHIP_RST field when pressed +class BQ25186Button final : public button::Button, public Parented { + public: + BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {} + + protected: + void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); } + + const uint8_t mask_; + const uint8_t value_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BUTTON diff --git a/esphome/components/bq25186/bq25186_switch.h b/esphome/components/bq25186/bq25186_switch.h new file mode 100644 index 00000000000..9abe6fec510 --- /dev/null +++ b/esphome/components/bq25186/bq25186_switch.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BQ25186_PG_GPO + +#include "bq25186.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::bq25186 { + +class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented { + protected: + void write_state(bool state) override { + if (this->parent_->write_pg_gpo_level(state)) + this->publish_state(state); + } +}; + +} // namespace esphome::bq25186 + +#endif // USE_BQ25186_PG_GPO diff --git a/esphome/components/bq25186/bq25186_text_sensor.cpp b/esphome/components/bq25186/bq25186_text_sensor.cpp new file mode 100644 index 00000000000..660313e6413 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.cpp @@ -0,0 +1,26 @@ +#include "bq25186_text_sensor.h" +#ifdef USE_TEXT_SENSOR + +#include "esphome/core/progmem.h" + +namespace esphome::bq25186 { + +// STAT0[6:5] charge status, then STAT1[4:3] TS status +PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled", + "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)"); + +void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03; + if (value == this->last_) + return; + this->last_ = value; + const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX); + char text[32]; + ESPHOME_strncpy_P(text, reinterpret_cast(name), sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.h b/esphome/components/bq25186/bq25186_text_sensor.h new file mode 100644 index 00000000000..33c8f806fa9 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_TEXT_SENSOR + +#include "bq25186.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::bq25186 { + +/// A two-bit status field; `first` is the string table index of the field's value 0 +class BQ25186TextSensor final : public text_sensor::TextSensor { + public: + BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {} + void on_status(const BQ25186StatusRegisters &status); + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t first_; + uint8_t last_{0xFF}; // the field value last published +}; + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/button.py b/esphome/components/bq25186/button.py new file mode 100644 index 00000000000..25527ff9a27 --- /dev/null +++ b/esphome/components/bq25186/button.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ICON_POWER, + ICON_RESTART, + ICON_RESTART_ALERT, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button) + +# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes +SOFTWARE_RESET_MASK = 0x80 +MODE_MASK = 0x60 + +# (key, SHIP_RST mask, value, device class, icon) +_BUTTONS = ( + ("software_reset", SOFTWARE_RESET_MASK, 0x80, DEVICE_CLASS_RESTART, ICON_RESTART), + ("shutdown", MODE_MASK, 0x20, cv.UNDEFINED, ICON_POWER), + ("ship_mode", MODE_MASK, 0x40, cv.UNDEFINED, ICON_POWER), + ("hardware_reset", MODE_MASK, 0x60, DEVICE_CLASS_RESTART, ICON_RESTART_ALERT), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): button.button_schema( + BQ25186Button, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=icon, + device_class=device_class, + ) + for key, _, _, device_class, icon in _BUTTONS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _BUTTONS)), +) + + +async def to_code(config: ConfigType) -> None: + for key, mask, value, _, _ in _BUTTONS: + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, mask, value) + await cg.register_parented(var, config[CONF_BQ25186_ID]) diff --git a/esphome/components/bq25186/switch.py b/esphome/components/bq25186/switch.py new file mode 100644 index 00000000000..e5eb2d98a2e --- /dev/null +++ b/esphome/components/bq25186/switch.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +CONF_PG_GPO = "pg_gpo" + +BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch) + +CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend( + { + cv.Required(CONF_PG_GPO): switch.switch_schema( + BQ25186PgGpoSwitch, default_restore_mode="DISABLED" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_BQ25186_PG_GPO") + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_PG_GPO, hub.set_pg_gpo_switch) diff --git a/esphome/components/bq25186/text_sensor.py b/esphome/components/bq25186/text_sensor.py new file mode 100644 index 00000000000..b27d2fbcfae --- /dev/null +++ b/esphome/components/bq25186/text_sensor.py @@ -0,0 +1,38 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor) + +REG_STAT0 = 0 +REG_STAT1 = 1 + +# (key, status register, bit shift, index of the field's value 0 in the C++ string table) +_FIELDS = ( + ("charge_status", REG_STAT0, 5, 0), + ("ts_status", REG_STAT1, 3, 4), +) + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor) + for key, *_ in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = text_sensor.sub_text_sensors(config) + for key, reg, shift, first in _FIELDS: + await sensors(key, partial(register_text_sensor, hub), reg, shift, first) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c645428c7c1..072abe83742 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -49,6 +49,9 @@ #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK #define USE_BMI270_BMM150 +#define USE_BQ25186_PG_GPO +#define BQ25186_BINARY_SENSOR_COUNT 1 +#define BQ25186_TEXT_SENSOR_COUNT 1 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py index 2ebf6087e7b..940bf5822fa 100644 --- a/tests/component_tests/bq25186/test_pack_registers.py +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -10,12 +10,16 @@ from esphome.components.bq25186 import ( CONF_FAST_CHARGE_CURRENT, CONF_MASK_TS_INTERRUPT, CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + CONF_WATCHDOG_15S_ENABLE, + CONFIG_SCHEMA, REG_ICHG_CTRL, REG_MASK_ID, REG_SHIP_RST, REG_VBAT_CTRL, + _check_watchdog, pack_registers, ) +from esphome.const import CONF_UPDATE_INTERVAL, CONF_WATCHDOG from esphome.types import ConfigType # Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the @@ -85,3 +89,49 @@ def test_masked_registers_only_carry_configurable_bits() -> None: def test_values_between_steps_are_rejected(key: str, value: str) -> None: with pytest.raises(cv.Invalid): BQ25186_CONFIG_SCHEMA({key: value}) + + +def _watchdog_config(interval: str, options: ConfigType) -> ConfigType: + config = BQ25186_CONFIG_SCHEMA(options) + config[CONF_UPDATE_INTERVAL] = cv.update_interval(interval) + return config + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "160s_software_reset"}), + ("60s", {CONF_WATCHDOG: "160s_hardware_reset"}), + ("30s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG: "disabled"}), + ("10s", {CONF_WATCHDOG_15S_ENABLE: True}), + ], +) +def test_update_interval_within_watchdog_passes( + interval: str, options: ConfigType +) -> None: + _check_watchdog(_watchdog_config(interval, options)) + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("40s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG_15S_ENABLE: True}), + ("20s", {CONF_WATCHDOG: "160s_software_reset", CONF_WATCHDOG_15S_ENABLE: True}), + ("never", {CONF_WATCHDOG: "160s_software_reset"}), + ], +) +def test_update_interval_past_watchdog_is_rejected( + interval: str, options: ConfigType +) -> None: + with pytest.raises(cv.Invalid, match=CONF_UPDATE_INTERVAL): + _check_watchdog(_watchdog_config(interval, options)) + + +def test_unconfigured_chip_keeps_its_power_on_watchdog() -> None: + config = {"configure_on_boot": False, "update_interval": "160s"} + with pytest.raises(cv.Invalid, match="shorter than 160 s"): + CONFIG_SCHEMA(config) + CONFIG_SCHEMA({**config, "update_interval": "60s"}) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 9f4b117cd3d..295a68d262b 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -23,3 +23,77 @@ bq25186: address: 0x6B configure_on_boot: true fast_charge_current: 20mA + watchdog: 160s_software_reset + update_interval: 30s + +binary_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + ts: + name: TS Open + charging: + name: Charging + charge_done: + name: Charge Done + ilim: + name: ILIM Active + vdppm: + name: VDPPM Active + vindpm: + name: VINDPM Active + thermreg: + name: Thermal Regulation Active + power_good: + name: VIN Power Good + vin_ovp: + name: VIN OVP Active + battery_uvlo: + name: Battery UVLO Active + safety_timer_flag: + name: Safety Timer Flag + wake1_flag: + name: Wake 1 Flag + wake2_flag: + name: Wake 2 Flag + ts_flag: + name: TS Flag + ilim_flag: + name: ILIM Flag + vdppm_flag: + name: VDPPM Flag + vindpm_flag: + name: VINDPM Flag + thermreg_flag: + name: Thermal Regulation Flag + vin_ovp_flag: + name: VIN OVP Flag + battery_uvlo_flag: + name: Battery UVLO Flag + battery_ocp_flag: + name: Battery OCP Flag + +text_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + charge_status: + name: Charge Status + ts_status: + name: TS Status + +button: + - platform: bq25186 + bq25186_id: bq25186_charger + software_reset: + name: Software Reset + shutdown: + name: Shutdown + ship_mode: + name: Ship Mode + hardware_reset: + name: Hardware Reset + +switch: + - platform: bq25186 + bq25186_id: bq25186_boot + pg_gpo: + name: PG GPO