diff --git a/CODEOWNERS b/CODEOWNERS index ff2b6eb327..e82158f390 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -554,6 +554,7 @@ esphome/components/syslog/* @clydebarrow esphome/components/systa_bus/* @Mat931 esphome/components/t6615/* @tylermenezes esphome/components/tas2780/* @remcom +esphome/components/tas58xx/* @mrtoy-me @remcom esphome/components/tc74/* @sethgirvan esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski diff --git a/esphome/components/tas58xx/__init__.py b/esphome/components/tas58xx/__init__.py new file mode 100644 index 0000000000..b87036984d --- /dev/null +++ b/esphome/components/tas58xx/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@mrtoy-me", "@remcom"] diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py new file mode 100644 index 0000000000..18a60a6645 --- /dev/null +++ b/esphome/components/tas58xx/audio_dac.py @@ -0,0 +1,176 @@ +from dataclasses import dataclass +import logging + +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.audio_dac import AudioDac +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ["i2c"] + +CONF_ANALOG_GAIN = "analog_gain" +CONF_DAC_MODE = "dac_mode" +CONF_IGNORE_ENABLE_PIN_WARNING = "ignore_enable_pin_warning" +CONF_MIXER_MODE = "mixer_mode" +CONF_VOLUME_MIN_DB = "volume_min_db" +CONF_VOLUME_MAX_DB = "volume_max_db" +CONF_TAS58XX_ID = "tas58xx_id" + +tas58xx_ns = cg.esphome_ns.namespace("tas58xx") +TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) + +DacMode = tas58xx_ns.enum("DacMode") +DAC_MODES = { + "btl": DacMode.DAC_MODE_BTL, + "pbtl": DacMode.DAC_MODE_PBTL, +} + +MixerMode = tas58xx_ns.enum("MixerMode") +MIXER_MODES = { + "stereo": MixerMode.MIXER_MODE_STEREO, + "stereo_inverse": MixerMode.MIXER_MODE_STEREO_INVERSE, + "mono": MixerMode.MIXER_MODE_MONO, + "left": MixerMode.MIXER_MODE_LEFT, + "right": MixerMode.MIXER_MODE_RIGHT, +} + + +@dataclass(frozen=True) +class Model: + """Limits of one model. The C++ side of a model is its ModelInfo constant.""" + + model_info: MockObj + analog_gain_min_db: float + volume_min_db: float + volume_max_db: float + default_address: int + + +MODELS: dict[str, Model] = { + "tas5805m": Model( + model_info=tas58xx_ns.TAS5805M_MODEL, + analog_gain_min_db=-15.5, + volume_min_db=-103.0, + volume_max_db=24.0, + default_address=0x2D, + ), +} + + +def _analog_gain_validator(model: Model): + range_validator = cv.All( + cv.decibel, cv.float_range(min=model.analog_gain_min_db, max=0.0) + ) + + def validator(value: float) -> float: + value = range_validator(value) + if value * 2 != int(value * 2): + raise cv.Invalid("analog_gain must be a multiple of 0.5 dB") + return value + + return validator + + +def _model_schema(model: Model) -> cv.Schema: + volume_validator = cv.All( + cv.decibel, + cv.float_range(min=model.volume_min_db, max=model.volume_max_db), + ) + return ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TAS58xx), + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_IGNORE_ENABLE_PIN_WARNING, default=False): cv.boolean, + cv.Optional( + CONF_ANALOG_GAIN, default=model.analog_gain_min_db + ): _analog_gain_validator(model), + cv.Optional(CONF_DAC_MODE, default="btl"): cv.enum( + DAC_MODES, lower=True + ), + cv.Optional(CONF_MIXER_MODE, default="stereo"): cv.enum( + MIXER_MODES, lower=True + ), + cv.Optional( + CONF_VOLUME_MIN_DB, default=model.volume_min_db + ): volume_validator, + cv.Optional( + CONF_VOLUME_MAX_DB, default=model.volume_max_db + ): volume_validator, + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(model.default_address)) + ) + + +def _validate_config(config: ConfigType) -> ConfigType: + if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: + raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") + if config[CONF_DAC_MODE] == "pbtl" and config[CONF_MIXER_MODE] in ( + "stereo", + "stereo_inverse", + ): + raise cv.Invalid( + f"{CONF_DAC_MODE} 'pbtl' drives a single speaker; use {CONF_MIXER_MODE} 'mono', 'left' or 'right'" + ) + if CONF_ENABLE_PIN in config: + if config[CONF_IGNORE_ENABLE_PIN_WARNING]: + raise cv.Invalid( + f"{CONF_IGNORE_ENABLE_PIN_WARNING} only applies when {CONF_ENABLE_PIN} is not set" + ) + elif not config[CONF_IGNORE_ENABLE_PIN_WARNING]: + # Without PDN high the device does not answer on I2C, and setup only reports an I2C failure + _LOGGER.warning( + "%s: %s not configured - if PDN (power down) is not hardwired high then add %s. " + "Set %s: true to hide this warning", + config[CONF_ID], + CONF_ENABLE_PIN, + CONF_ENABLE_PIN, + CONF_IGNORE_ENABLE_PIN_WARNING, + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + {name: _model_schema(model) for name, model in MODELS.items()}, + key=CONF_MODEL, + lower=True, + ), + _validate_config, +) + + +TAS58XX_ACTION_SCHEMA = maybe_simple_id({cv.GenerateID(): cv.use_id(TAS58xx)}) + +for _name, _call in ( + ("tas58xx.activate", "activate()"), + ("tas58xx.deactivate", "deactivate()"), +): + automation.register_apply_action( + _name, TAS58XX_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +async def to_code(config: ConfigType) -> None: + model = MODELS[config[CONF_MODEL]] + var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) + cg.add(var.set_dac_mode(config[CONF_DAC_MODE])) + cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE])) + cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) + cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) + if enable_pin_config := config.get(CONF_ENABLE_PIN): + enable_pin = await cg.gpio_pin_expression(enable_pin_config) + cg.add(var.set_enable_pin(enable_pin)) diff --git a/esphome/components/tas58xx/binary_sensor.py b/esphome/components/tas58xx/binary_sensor.py new file mode 100644 index 0000000000..8a5bb03dd7 --- /dev/null +++ b/esphome/components/tas58xx/binary_sensor.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .audio_dac import CONF_TAS58XX_ID, TAS58xx, tas58xx_ns + +CONF_HAVE_FAULT = "have_fault" + +# Each name matches a FaultSensor value in tas58xx.h +FAULT_SENSORS = ( + "left_channel_dc_fault", + "right_channel_dc_fault", + "left_channel_over_current", + "right_channel_over_current", + "otp_crc_check", + "bq_write_failed", + "clock_fault", + "pvdd_over_voltage", + "pvdd_under_voltage", + "over_temp_shutdown", + "over_temp_warning", +) + +FaultSensor = tas58xx_ns.enum("FaultSensor") + +_FAULT_SCHEMA = binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_TAS58XX_ID): cv.use_id(TAS58xx), + cv.Optional(CONF_HAVE_FAULT): _FAULT_SCHEMA, + **{cv.Optional(key): _FAULT_SCHEMA for key in FAULT_SENSORS}, + } +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_TAS58XX_ID]) + if sensor_config := config.get(CONF_HAVE_FAULT): + sens = await binary_sensor.new_binary_sensor(sensor_config) + cg.add(parent.set_have_fault_binary_sensor(sens)) + for key in FAULT_SENSORS: + if sensor_config := config.get(key): + sens = await binary_sensor.new_binary_sensor(sensor_config) + fault = getattr(FaultSensor, f"FAULT_SENSOR_{key.upper()}") + cg.add(parent.set_fault_binary_sensor(fault, sens)) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp new file mode 100644 index 0000000000..e5eb5a837e --- /dev/null +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -0,0 +1,84 @@ +#include "tas58xx.h" + +#include "esphome/core/hal.h" + +namespace esphome::tas58xx { + +// Remainder of the startup sequence from TI PurePath Console, run after the reset. Register 0x00 selects the page. +// Registers 0x46, 0x7D, 0x7E and page 1 register 0x51 are not documented in the datasheet. +static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { + {0x03, 0x00}, // DEVICE_CTRL_2: deep sleep + {0x46, 0x01}, + {0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z + // The I2C address is latched at power up, after which the ADR pin can report faults + {0x61, 0x0B}, // ADR_PIN_CONFIG: FAULTZ + {0x60, 0x01}, // ADR_PIN_CTRL: output + {0x7D, 0x11}, + {0x7E, 0xFF}, + {0x00, 0x01}, + {0x51, 0x05}, + {0x00, 0x00}, +}; + +static const LogString *model_name() { return LOG_STR("TAS5805M"); } + +// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266. +static const LogString *fault_name(uint8_t index) { + if (index == 0) + return LOG_STR("Right channel over current"); + if (index == 1) + return LOG_STR("Left channel over current"); + if (index == 2) + return LOG_STR("Right channel DC fault"); + if (index == 3) + return LOG_STR("Left channel DC fault"); + if (index == 8) + return LOG_STR("PVDD under voltage"); + if (index == 9) + return LOG_STR("PVDD over voltage"); + if (index == 10) + return LOG_STR("Clock fault"); + if (index == 14) + return LOG_STR("BQ write failed"); + if (index == 15) + return LOG_STR("OTP CRC check error"); + if (index == 16) + return LOG_STR("Over temperature shutdown"); + if (index == 26) + return LOG_STR("Over temperature warning"); + return LOG_STR("Unknown fault"); +} + +const ModelInfo TAS5805M_MODEL = { + .name = model_name, + .startup_sequence = STARTUP_SEQUENCE, + .startup_sequence_length = sizeof(STARTUP_SEQUENCE) / sizeof(STARTUP_SEQUENCE[0]), + .mixer_book = 0x8C, + .mixer_page = 0x29, + .mixer_register = 0x18, + // The clock fault is left out of the log and have_fault: it is set whenever the I2S clock stops, which is normal + .fault_error_mask = 0x0001C30F, + .fault_warning_mask = 0x04000000, + // DC and over current faults keep the output off until cleared (datasheet 7.5.3.3.1, 7.5.3.3.2). They are not + // cleared automatically: a DC fault re-trips only after 570 ms, so a clear on every poll would pass DC to the + // speaker. + .fault_output_off_mask = 0x0000000F, + .fault_latched_mask = 0x0401C70F, + .fault_name = fault_name, + .fault_sensor_bits = + { + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + }, +}; + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp new file mode 100644 index 0000000000..458150a125 --- /dev/null +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -0,0 +1,337 @@ +#include "tas58xx.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::tas58xx { + +static const char *const TAG = "tas58xx"; + +static constexpr uint8_t TAS58XX_PAGE_SELECT = 0x00; // Page Select, in every book +static constexpr uint8_t TAS58XX_BOOK_SELECT = 0x7F; // Book Select, on page 0 of every book +static constexpr uint8_t TAS58XX_BOOK_CONTROL = 0x00; +static constexpr uint8_t TAS58XX_PAGE_0 = 0x00; + +/* BOOK 0x00, PAGE 0x00 */ +static constexpr uint8_t TAS58XX_RESET_CTRL = 0x01; +static constexpr uint8_t TAS58XX_RESET_CTRL_ALL = 0x11; // Reset DSP/control port and registers +static constexpr uint8_t TAS58XX_DEVICE_CTRL_1 = 0x02; +static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_PBTL = (1 << 2); +static constexpr uint8_t TAS58XX_DEVICE_CTRL_2 = 0x03; +static constexpr uint8_t TAS58XX_DEVICE_CTRL_2_MUTE = (1 << 3); +static constexpr uint8_t TAS58XX_CTRL_STATE_MASK = 0x03; // DEVICE_CTRL_2 and POWER_STATE +static constexpr uint8_t TAS58XX_CTRL_STATE_DEEP_SLEEP = 0x00; +static constexpr uint8_t TAS58XX_CTRL_STATE_SLEEP = 0x01; +static constexpr uint8_t TAS58XX_CTRL_STATE_HIZ = 0x02; +static constexpr uint8_t TAS58XX_CTRL_STATE_PLAY = 0x03; +static constexpr uint8_t TAS58XX_CTRL_STATE_UNKNOWN = 0xFF; // Not a device value, forces the next update to act +static constexpr uint8_t TAS58XX_DIG_VOL = 0x4C; // 0x00 = +24 dB to 0xFE = -103 dB in 0.5 dB steps, 0xFF = mute +static constexpr uint8_t TAS58XX_DIG_VOL_0DB = 0x30; +static constexpr uint8_t TAS58XX_DIG_VOL_MINUS_103DB = 0xFE; +static constexpr uint8_t TAS58XX_AGAIN = 0x54; // 0x00 = 0 dB to 0x1F = -15.5 dB in 0.5 dB steps +static constexpr uint8_t TAS58XX_AGAIN_MINUS_15_5DB = 0x1F; +static constexpr uint8_t TAS58XX_POWER_STATE = 0x68; +static constexpr uint8_t TAS58XX_CHAN_FAULT = 0x70; // GLOBAL_FAULT1, GLOBAL_FAULT2 and OT_WARNING follow +static constexpr uint8_t TAS58XX_FAULT_REGISTER_COUNT = 4; +static constexpr uint8_t TAS58XX_FAULT_CLEAR = 0x78; +static constexpr uint8_t TAS58XX_FAULT_CLEAR_ANALOG = 0x80; + +/* Input mixer, four 9.23 fixed point big endian coefficients at a location that differs per model */ +static constexpr uint8_t TAS58XX_MIXER_COEFFICIENT_SIZE = 4; +// Only the second byte differs between mute, -6 dB and 0 dB +static constexpr uint8_t TAS58XX_MIXER_MUTE = 0x00; +static constexpr uint8_t TAS58XX_MIXER_MINUS_6DB = 0x40; +static constexpr uint8_t TAS58XX_MIXER_0DB = 0x80; + +static constexpr uint32_t PDN_LOW_MS = 1; +static constexpr uint32_t PDN_TO_I2C_MS = 5; // Minimum time from PDN high to I2C access +static constexpr uint32_t RESET_SETTLE_MS = 5; + +static const LogString *power_state_name(uint8_t state) { + if (state == TAS58XX_CTRL_STATE_DEEP_SLEEP) + return LOG_STR("Deep sleep"); + if (state == TAS58XX_CTRL_STATE_SLEEP) + return LOG_STR("Sleep"); + if (state == TAS58XX_CTRL_STATE_HIZ) + return LOG_STR("Hi-Z"); + return LOG_STR("Play"); +} + +void TAS58xx::setup() { + if (this->enable_pin_ != nullptr) { + this->enable_pin_->setup(); + this->enable_pin_->digital_write(false); + delay(PDN_LOW_MS); + this->enable_pin_->digital_write(true); + delay(PDN_TO_I2C_MS); + } + if (!this->init_()) { + this->mark_failed(); + } +} + +bool TAS58xx::select_book_page_(uint8_t book, uint8_t page) { + // The book can only be changed from page 0 + return this->write_byte(TAS58XX_PAGE_SELECT, TAS58XX_PAGE_0) && this->write_byte(TAS58XX_BOOK_SELECT, book) && + this->write_byte(TAS58XX_PAGE_SELECT, page); +} + +bool TAS58xx::init_() { + // Header of every TI PurePath Console export: select book 0 in case the MCU restarted without a PDN toggle, + // silence the output with Hi-Z, reset the DSP and the control registers, then return to Hi-Z + if (!this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0) || + !this->write_byte(TAS58XX_DEVICE_CTRL_2, TAS58XX_CTRL_STATE_HIZ) || + !this->write_byte(TAS58XX_RESET_CTRL, TAS58XX_RESET_CTRL_ALL) || + !this->write_byte(TAS58XX_DEVICE_CTRL_2, TAS58XX_CTRL_STATE_HIZ)) { + ESP_LOGE(TAG, "I2C write failed during reset"); + return false; + } + delay(RESET_SETTLE_MS); + + for (uint8_t i = 0; i < this->model_->startup_sequence_length; i++) { + const uint8_t *entry = this->model_->startup_sequence[i]; + if (!this->write_byte(progmem_read_byte(&entry[0]), progmem_read_byte(&entry[1]))) { + ESP_LOGE(TAG, "I2C write failed during init"); + return false; + } + } + + // Setters are public, so keep the value inside the register range + const uint8_t again = + static_cast(lroundf(clamp(-this->analog_gain_db_ * 2.0f, 0.0f, float{TAS58XX_AGAIN_MINUS_15_5DB}))); + if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) || + !this->write_byte(TAS58XX_AGAIN, again) || !this->write_volume_() || + !this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) || + !this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) { + ESP_LOGE(TAG, "I2C write failed during init"); + return false; + } + // The mixer is written once the I2S clocks run, see on_audio_started() and update() + this->power_state_ = TAS58XX_CTRL_STATE_UNKNOWN; + this->mixer_written_ = false; + return true; +} + +void TAS58xx::activate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "[0x%02X] Activating", this->address_); + // A failed mixer write can leave the device in another book + if (!this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0)) { + ESP_LOGE(TAG, "Failed to select the control port"); + return; + } + // Also the way to restart the output after a DC or over current fault + if (!this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) { + ESP_LOGW(TAG, "Failed to clear faults"); + } + const bool muted = this->is_muted_; + // Leaving deep sleep needs this sequence to reset the internal state machine, see datasheet 7.4.5 + if (this->ctrl_state_ == TAS58XX_CTRL_STATE_DEEP_SLEEP && + !(this->write_ctrl_state_(TAS58XX_CTRL_STATE_HIZ, muted) && + this->write_ctrl_state_(TAS58XX_CTRL_STATE_DEEP_SLEEP, muted) && + this->write_ctrl_state_(TAS58XX_CTRL_STATE_HIZ, muted))) { + return; + } + this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, muted); +} + +void TAS58xx::deactivate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "[0x%02X] Deactivating", this->address_); + if (!this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0)) { + ESP_LOGE(TAG, "Failed to select the control port"); + return; + } + // The DSP, and with it the mixer, stays active in deep sleep + this->write_ctrl_state_(TAS58XX_CTRL_STATE_DEEP_SLEEP, this->is_muted_); +} + +bool TAS58xx::write_ctrl_state_(uint8_t state, bool muted) { + if (!this->write_byte(TAS58XX_DEVICE_CTRL_2, state | (muted ? TAS58XX_DEVICE_CTRL_2_MUTE : 0))) { + ESP_LOGE(TAG, "Failed to write DEVICE_CTRL_2"); + return false; + } + this->ctrl_state_ = state; + return true; +} + +// Returns false if the fault registers could not be read +bool TAS58xx::read_faults_() { + uint8_t fault_registers[TAS58XX_FAULT_REGISTER_COUNT]; + if (!this->read_bytes(TAS58XX_CHAN_FAULT, fault_registers, sizeof(fault_registers))) + return false; + uint32_t faults = 0; + for (uint8_t reg = 0; reg < TAS58XX_FAULT_REGISTER_COUNT; reg++) + faults |= uint32_t{fault_registers[reg]} << (reg * 8); + const ModelInfo &model = *this->model_; + const uint32_t active = faults & (model.fault_error_mask | model.fault_warning_mask); + + // Clearing makes a lasting condition latch again on every poll, so only log changes + const uint32_t changed = active ^ this->logged_faults_; + for (uint8_t index = 0; index < 32; index++) { + const uint32_t mask = uint32_t{1} << index; + if (!(changed & mask)) + continue; + const LogString *name = model.fault_name(index); + if (!(active & mask)) { + ESP_LOGI(TAG, "[0x%02X] %s cleared", this->address_, LOG_STR_ARG(name)); + } else if (model.fault_error_mask & mask) { + ESP_LOGE(TAG, "[0x%02X] %s", this->address_, LOG_STR_ARG(name)); + } else { + ESP_LOGW(TAG, "[0x%02X] %s", this->address_, LOG_STR_ARG(name)); + } + } + if (changed & active & model.fault_output_off_mask) { + ESP_LOGW(TAG, "[0x%02X] Output stays off: fix the cause, then call tas58xx.activate or power cycle the amplifier", + this->address_); + } + this->logged_faults_ = active; + +#ifdef USE_BINARY_SENSOR + if (this->have_fault_binary_sensor_ != nullptr) + this->have_fault_binary_sensor_->publish_state((active & model.fault_error_mask) != 0); + for (uint8_t fault = 0; fault < FAULT_SENSOR_COUNT; fault++) { + if (this->fault_binary_sensors_[fault] != nullptr) + this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << model.fault_sensor_bits[fault])); + } +#endif + + // Latched fault bits stay set after the condition is gone and only report it; clear those so the bits, and the + // sensors, follow the current state. The clear register resets all faults at once, so hold off while the output + // is off. + if ((faults & model.fault_latched_mask) && !(active & model.fault_output_off_mask) && + !this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) { + ESP_LOGW(TAG, "Failed to clear faults"); + } + return true; +} + +void TAS58xx::on_audio_started() { + // DSP coefficients can only be written with a running I2S clock (datasheet 7.5.3.1) and are kept when it stops + if (this->is_failed() || this->mixer_written_) + return; + if (!this->write_mixer_()) { + ESP_LOGW(TAG, "Failed to write mixer"); + } +} + +void TAS58xx::update() { + uint8_t power_state; + if (!this->read_faults_() || !this->read_byte(TAS58XX_POWER_STATE, &power_state)) { + this->status_set_warning(LOG_STR("Failed to read status")); + return; + } + this->status_clear_warning(); + + power_state &= TAS58XX_CTRL_STATE_MASK; + if (power_state != this->power_state_) { + ESP_LOGD(TAG, "[0x%02X] Power state: %s", this->address_, LOG_STR_ARG(power_state_name(power_state))); + this->power_state_ = power_state; + } + // The device only plays with a running I2S clock. Fallback for speakers that do not call on_audio_started(); + // a failed write is retried on the next update. + if (power_state == TAS58XX_CTRL_STATE_PLAY && !this->mixer_written_ && !this->write_mixer_()) { + ESP_LOGW(TAG, "Failed to write mixer"); + } +} + +void TAS58xx::dump_config() { + const LogString *mixer_mode; + if (this->mixer_mode_ == MIXER_MODE_STEREO_INVERSE) { + mixer_mode = LOG_STR("Stereo inverse"); + } else if (this->mixer_mode_ == MIXER_MODE_MONO) { + mixer_mode = LOG_STR("Mono"); + } else if (this->mixer_mode_ == MIXER_MODE_LEFT) { + mixer_mode = LOG_STR("Left"); + } else if (this->mixer_mode_ == MIXER_MODE_RIGHT) { + mixer_mode = LOG_STR("Right"); + } else { + mixer_mode = LOG_STR("Stereo"); + } + ESP_LOGCONFIG(TAG, + "Audio Amplifier:\n" + " Model: %s", + LOG_STR_ARG(this->model_->name())); + LOG_I2C_DEVICE(this); + LOG_PIN(" Enable Pin: ", this->enable_pin_); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " Analog Gain: %.1f dB\n" + " DAC Mode: %s\n" + " Mixer Mode: %s\n" + " Volume Range: %.1f dB - %.1f dB", + this->analog_gain_db_, + this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"), + LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Any Fault", this->have_fault_binary_sensor_); + for (auto *sensor : this->fault_binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Fault", sensor); + } +#endif +} + +bool TAS58xx::set_mute_(bool muted) { + if (!this->write_ctrl_state_(this->ctrl_state_, muted)) + return false; + this->is_muted_ = muted; + return true; +} + +bool TAS58xx::set_volume(float volume) { + float previous = this->volume_; + this->volume_ = clamp(volume, 0.0f, 1.0f); + if (!this->write_volume_()) { + this->volume_ = previous; + return false; + } + return true; +} + +bool TAS58xx::write_volume_() { + // volume 0.0 maps to volume_min_db_, which is only close to silence at -103 dB + const float volume_db = std::lerp(this->volume_min_db_, this->volume_max_db_, this->volume_); + const uint8_t dig_vol = static_cast( + lroundf(clamp(TAS58XX_DIG_VOL_0DB - volume_db * 2.0f, 0.0f, float{TAS58XX_DIG_VOL_MINUS_103DB}))); + ESP_LOGV(TAG, "Setting volume to 0x%02X", dig_vol); + return this->write_byte(TAS58XX_DIG_VOL, dig_vol); +} + +bool TAS58xx::write_mixer_() { + uint8_t left_to_left = TAS58XX_MIXER_0DB; + uint8_t right_to_left = TAS58XX_MIXER_MUTE; + uint8_t left_to_right = TAS58XX_MIXER_MUTE; + uint8_t right_to_right = TAS58XX_MIXER_0DB; + if (this->mixer_mode_ == MIXER_MODE_STEREO_INVERSE) { + left_to_left = TAS58XX_MIXER_MUTE; + right_to_left = TAS58XX_MIXER_0DB; + left_to_right = TAS58XX_MIXER_0DB; + right_to_right = TAS58XX_MIXER_MUTE; + } else if (this->mixer_mode_ == MIXER_MODE_MONO) { + left_to_left = TAS58XX_MIXER_MINUS_6DB; + right_to_left = TAS58XX_MIXER_MINUS_6DB; + left_to_right = TAS58XX_MIXER_MINUS_6DB; + right_to_right = TAS58XX_MIXER_MINUS_6DB; + } else if (this->mixer_mode_ == MIXER_MODE_LEFT) { + left_to_right = TAS58XX_MIXER_0DB; + right_to_right = TAS58XX_MIXER_MUTE; + } else if (this->mixer_mode_ == MIXER_MODE_RIGHT) { + left_to_left = TAS58XX_MIXER_MUTE; + right_to_left = TAS58XX_MIXER_0DB; + } + const uint8_t coefficients[4 * TAS58XX_MIXER_COEFFICIENT_SIZE] = {0, left_to_left, 0, 0, 0, right_to_left, 0, 0, + 0, left_to_right, 0, 0, 0, right_to_right, 0, 0}; + bool ok = this->select_book_page_(this->model_->mixer_book, this->model_->mixer_page) && + this->write_bytes(this->model_->mixer_register, coefficients, sizeof(coefficients)); + // Always return to the control port, even after a failed write + ok = this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0) && ok; + this->mixer_written_ = ok; + return ok; +} + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.h b/esphome/components/tas58xx/tas58xx.h new file mode 100644 index 0000000000..b1e9a531dd --- /dev/null +++ b/esphome/components/tas58xx/tas58xx.h @@ -0,0 +1,140 @@ +#pragma once + +#include + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::tas58xx { + +enum DacMode : uint8_t { + DAC_MODE_BTL = 0, // Bridge tied load, two speakers + DAC_MODE_PBTL = 1, // Parallel bridge tied load, one speaker +}; + +enum MixerMode : uint8_t { + MIXER_MODE_STEREO = 0, + MIXER_MODE_STEREO_INVERSE, + MIXER_MODE_MONO, + MIXER_MODE_LEFT, + MIXER_MODE_RIGHT, +}; + +/// Fault binary sensors that map to a single fault bit. The Python FAULT_SENSORS list uses the same names. +enum FaultSensor : uint8_t { + FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT = 0, + FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT, + FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT, + FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT, + FAULT_SENSOR_OTP_CRC_CHECK, + FAULT_SENSOR_BQ_WRITE_FAILED, + FAULT_SENSOR_CLOCK_FAULT, + FAULT_SENSOR_PVDD_OVER_VOLTAGE, + FAULT_SENSOR_PVDD_UNDER_VOLTAGE, + FAULT_SENSOR_OVER_TEMP_SHUTDOWN, + FAULT_SENSOR_OVER_TEMP_WARNING, + FAULT_SENSOR_COUNT, +}; + +/// Everything that differs between models of the family. One constant instance exists per model, see +/// model_*.cpp, and each TAS58xx instance points to the one for its model. +/// +/// Fault bits are packed into a 32 bit word: CHAN_FAULT, GLOBAL_FAULT1, GLOBAL_FAULT2 and OT_WARNING, one byte +/// each, from low to high. A bit index is register * 8 + bit. +struct ModelInfo { + const LogString *(*name)(); + /// Remainder of the startup sequence, run after the reset, as {register, value} pairs in PROGMEM + const uint8_t (*startup_sequence)[2]; + uint8_t startup_sequence_length; + /// Location of the four 9.23 fixed point input mixer coefficients: LEFT_TO_LEFT, RIGHT_TO_LEFT, LEFT_TO_RIGHT, + /// RIGHT_TO_RIGHT + uint8_t mixer_book; + uint8_t mixer_page; + uint8_t mixer_register; + /// Faults logged as errors and reported by have_fault + uint32_t fault_error_mask; + /// Faults logged as warnings + uint32_t fault_warning_mask; + /// Faults that keep the output off until cleared by activate() + uint32_t fault_output_off_mask; + /// Faults that stay set after the condition is gone and are cleared after each read + uint32_t fault_latched_mask; + const LogString *(*fault_name)(uint8_t index); + /// Bit index for each FaultSensor + uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT]; +}; + +extern const ModelInfo TAS5805M_MODEL; + +class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c::I2CDevice { + public: + explicit TAS58xx(const ModelInfo *model) : model_(model) {} + + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + void update() override; + + /// Clear faults, leave deep sleep and switch to play; the device waits in Hi-Z until an I2S clock is present. + /// This is also how the output is restarted after a DC or over current fault. + void activate(); + /// Switch to deep sleep, the lowest power state that keeps I2C and the DSP running. + void deactivate(); + + bool set_mute_off() override { return this->set_mute_(false); } + bool set_mute_on() override { return this->set_mute_(true); } + bool set_volume(float volume) override; + + bool is_muted() override { return this->is_muted_; } + float volume() override { return this->volume_; } + + void on_audio_started() override; + + void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; } + void set_analog_gain(float analog_gain_db) { this->analog_gain_db_ = analog_gain_db; } + void set_dac_mode(DacMode dac_mode) { this->dac_mode_ = dac_mode; } + void set_mixer_mode(MixerMode mixer_mode) { this->mixer_mode_ = mixer_mode; } + void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; } + void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; } + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(have_fault) + void set_fault_binary_sensor(FaultSensor fault, binary_sensor::BinarySensor *sensor) { + this->fault_binary_sensors_[fault] = sensor; + } +#endif + + protected: + bool select_book_page_(uint8_t book, uint8_t page); + bool init_(); + bool write_ctrl_state_(uint8_t state, bool muted); + bool set_mute_(bool muted); + bool write_volume_(); + bool write_mixer_(); + bool read_faults_(); + + const ModelInfo *model_; + GPIOPin *enable_pin_{nullptr}; +#ifdef USE_BINARY_SENSOR + std::array fault_binary_sensors_{}; +#endif + float volume_{0}; + float analog_gain_db_{-15.5f}; + float volume_min_db_{-103.0f}; + float volume_max_db_{24.0f}; + uint32_t logged_faults_{0}; // Fault bits as last logged, packed like the fault masks + DacMode dac_mode_{DAC_MODE_BTL}; + MixerMode mixer_mode_{MIXER_MODE_STEREO}; + uint8_t ctrl_state_{0}; + uint8_t power_state_{0xFF}; // Last POWER_STATE seen by update(), 0xFF until the first read + bool mixer_written_{false}; // Mixer written since the last reset +}; + +} // namespace esphome::tas58xx diff --git a/tests/components/tas58xx/common-speaker.yaml b/tests/components/tas58xx/common-speaker.yaml new file mode 100644 index 0000000000..c8ad45c880 --- /dev/null +++ b/tests/components/tas58xx/common-speaker.yaml @@ -0,0 +1,7 @@ +speaker: + - platform: i2s_audio + id: tas58xx_speaker + i2s_audio_id: i2s_audio_bus + audio_dac: tas58xx_amp + dac_type: external + i2s_dout_pin: GPIO13 diff --git a/tests/components/tas58xx/common.yaml b/tests/components/tas58xx/common.yaml new file mode 100644 index 0000000000..e12ddf7567 --- /dev/null +++ b/tests/components/tas58xx/common.yaml @@ -0,0 +1,61 @@ +audio_dac: + - platform: tas58xx + model: tas5805m + id: tas58xx_amp + i2c_id: i2c_bus + address: 0x2D + enable_pin: GPIO12 + analog_gain: -15.5dB + dac_mode: btl + mixer_mode: stereo + volume_min_db: -60dB + volume_max_db: 0dB + update_interval: 1s + - platform: tas58xx + model: TAS5805M + id: tas58xx_amp_2 + i2c_id: i2c_bus + address: 0x2C + ignore_enable_pin_warning: true + +esphome: + on_boot: + - tas58xx.deactivate: tas58xx_amp + - tas58xx.activate: + id: tas58xx_amp + - tas58xx.activate: tas58xx_amp_2 + +binary_sensor: + - platform: tas58xx + tas58xx_id: tas58xx_amp + have_fault: + name: Any Fault + left_channel_dc_fault: + name: Left Channel DC Fault + right_channel_dc_fault: + name: Right Channel DC Fault + left_channel_over_current: + name: Left Channel Over Current + right_channel_over_current: + name: Right Channel Over Current + otp_crc_check: + name: OTP CRC Check + bq_write_failed: + name: BQ Write Failed + clock_fault: + name: Clock Fault + pvdd_over_voltage: + name: PVDD Over Voltage + pvdd_under_voltage: + name: PVDD Under Voltage + over_temp_shutdown: + name: Over Temperature Shutdown + over_temp_warning: + name: Over Temperature Warning + + - platform: tas58xx + tas58xx_id: tas58xx_amp_2 + have_fault: + name: Second Amp Any Fault + left_channel_dc_fault: + name: Second Amp Left Channel DC Fault diff --git a/tests/components/tas58xx/test.esp32-idf.yaml b/tests/components/tas58xx/test.esp32-idf.yaml new file mode 100644 index 0000000000..a868a2ac03 --- /dev/null +++ b/tests/components/tas58xx/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + tas58xx: !include common.yaml + tas58xx_speaker: !include common-speaker.yaml diff --git a/tests/components/tas58xx/test.esp8266-ard.yaml b/tests/components/tas58xx/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4f95cb4fd0 --- /dev/null +++ b/tests/components/tas58xx/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + tas58xx: !include common.yaml diff --git a/tests/components/tas58xx/test.rp2040-ard.yaml b/tests/components/tas58xx/test.rp2040-ard.yaml new file mode 100644 index 0000000000..5fcf8e9357 --- /dev/null +++ b/tests/components/tas58xx/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + tas58xx: !include common.yaml diff --git a/tests/components/tas58xx/validate.esp32-idf.yaml b/tests/components/tas58xx/validate.esp32-idf.yaml new file mode 100644 index 0000000000..9a94137bbb --- /dev/null +++ b/tests/components/tas58xx/validate.esp32-idf.yaml @@ -0,0 +1,11 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +audio_dac: + - platform: tas58xx + model: tas5805m + id: tas58xx_pbtl + i2c_id: i2c_bus + dac_mode: PBTL + mixer_mode: mono + analog_gain: -3.5dB