From 960316abd6835c4e18422cce1ffe4d8b59d666a4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:17:44 -0400 Subject: [PATCH 1/4] [core] Percent-encode "/" in git credentials (#21653) --- esphome/git.py | 6 ++--- tests/unit_tests/test_git.py | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/esphome/git.py b/esphome/git.py index b4060ec8ca9..933cb194c94 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -718,9 +718,9 @@ def _clone_or_update_locked( # compute the same cache key as this one. original_url = url if username is not None and password is not None: - url = url.replace( - "://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@" - ) + user = urllib.parse.quote(username, safe="") + token = urllib.parse.quote(password, safe="") + url = url.replace("://", f"://{user}:{token}@") hash_dir_name = _compute_destination_path(key, domain).name repo_dir = _repo_entry_dir(key, domain, subpath) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 5adcddd1c8a..376c719814b 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -10,6 +10,7 @@ import threading import time from typing import Any from unittest.mock import Mock, patch +import urllib.parse from filelock import FileLock import pytest @@ -2097,6 +2098,48 @@ def test_recovery_reclone_keeps_credentials_and_cache_key( assert clone_url.count("@") == 1 +def test_clone_credentials_with_slash_are_escaped( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A "/" in the username or password must be percent-encoded, or the + userinfo ends at the "/" and git reports the password in its error.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + username="team/user", + password="ab/cd@ef", + ) + + clone_cmds = [ + c[0][0] + for c in mock_run_git_command.call_args_list + if _get_git_command_type(c[0][0]) == "clone" + ] + assert clone_cmds + clone_url = clone_cmds[0][-2] + assert clone_url == "https://team%2Fuser:ab%2Fcd%40ef@github.com/test/repo" + assert urllib.parse.urlsplit(clone_url).hostname == "github.com" + redacted = git._redact_url_credentials(f"fatal: unable to access '{clone_url}/'") + assert redacted == "fatal: unable to access 'https://***@github.com/test/repo/'" + + def test_refresh_submodule_failure_recovers_then_raises( tmp_path: Path, mock_run_git_command: Mock ) -> None: From 6cb622417ab5bf0d89c880506800b76b2e7777a0 Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:18:18 +1000 Subject: [PATCH 2/4] [tas58xx] Add fault_output and modulation options (#21242) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/tas58xx/audio_dac.py | 23 +++++++++++ esphome/components/tas58xx/model_tas5805m.cpp | 9 ++-- esphome/components/tas58xx/model_tas5825m.cpp | 17 +++++--- esphome/components/tas58xx/tas58xx.cpp | 41 ++++++++++++++++--- esphome/components/tas58xx/tas58xx.h | 17 ++++++++ tests/component_tests/tas58xx/test_tas58xx.py | 31 ++++++++++++++ .../test_tas58xx_fault_output_modulation.yaml | 26 ++++++++++++ tests/components/tas58xx/common.yaml | 2 + .../tas58xx/validate.esp32-idf.yaml | 1 + 9 files changed, 152 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/tas58xx/test_tas58xx.py create mode 100644 tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 4a23e085ea0..48c7133b941 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -6,6 +6,7 @@ from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac +from esphome.components.const import CONF_MODULATION import esphome.config_validation as cv from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL from esphome.cpp_generator import MockObj @@ -19,6 +20,7 @@ DEPENDENCIES = ["i2c"] CONF_ANALOG_GAIN = "analog_gain" CONF_DAC_MODE = "dac_mode" +CONF_FAULT_OUTPUT = "fault_output" CONF_IGNORE_ENABLE_PIN_WARNING = "ignore_enable_pin_warning" CONF_MIXER_MODE = "mixer_mode" CONF_VOLUME_MIN_DB = "volume_min_db" @@ -39,6 +41,11 @@ MIXER_MODES = MixerMode.options( "stereo", "stereo_inverse", "mono", "left", "right", prefix="MIXER_MODE_" ) +Modulation = tas58xx_ns.enum("Modulation") +MODULATIONS = Modulation.options("bd_mode", "1spw_mode", prefix="MODULATION_") + +FAULT_OUTPUT_NONE = "none" + @dataclass(frozen=True) class Model: @@ -49,6 +56,9 @@ class Model: volume_min_db: float volume_max_db: float default_address: int + # Pins that can report FAULTZ: name -> pin number n, selected at 0x61 + n (ModelInfo::fault_output_pins) + fault_outputs: dict[str, int] + default_fault_output: str MODELS: dict[str, Model] = { @@ -58,6 +68,8 @@ MODELS: dict[str, Model] = { volume_min_db=-103.0, volume_max_db=24.0, default_address=0x2D, + fault_outputs={"adr": 0}, + default_fault_output="adr", ), DAC_TAS5825M: Model( model_info=tas58xx_ns.TAS5825M_MODEL, @@ -65,6 +77,8 @@ MODELS: dict[str, Model] = { volume_min_db=-103.0, volume_max_db=24.0, default_address=0x4C, + fault_outputs={"gpio0": 0, "gpio1": 1, "gpio2": 2}, + default_fault_output="gpio1", ), } @@ -100,9 +114,15 @@ def _model_schema(model: Model) -> cv.Schema: cv.Optional(CONF_DAC_MODE, default="btl"): cv.enum( DAC_MODES, lower=True ), + cv.Optional( + CONF_FAULT_OUTPUT, default=model.default_fault_output + ): cv.one_of(FAULT_OUTPUT_NONE, *model.fault_outputs, lower=True), cv.Optional(CONF_MIXER_MODE, default="stereo"): cv.enum( MIXER_MODES, lower=True ), + cv.Optional(CONF_MODULATION, default="bd_mode"): cv.enum( + MODULATIONS, lower=True + ), cv.Optional( CONF_VOLUME_MIN_DB, default=model.volume_min_db ): volume_validator, @@ -172,7 +192,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) cg.add(var.set_dac_mode(config[CONF_DAC_MODE])) + if (fault_output := config[CONF_FAULT_OUTPUT]) != FAULT_OUTPUT_NONE: + cg.add(var.set_fault_output(model.fault_outputs[fault_output])) cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE])) + cg.add(var.set_modulation(config[CONF_MODULATION])) cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index 147d0dcc89c..0996e552bec 100644 --- a/esphome/components/tas58xx/model_tas5805m.cpp +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -8,11 +8,8 @@ namespace esphome::tas58xx { // Registers 0x46, 0x7D, 0x7E and page 1 register 0x51 are not documented in the datasheet. static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = { {0x03, 0x00}, // DEVICE_CTRL_2: deep sleep - {0x46, 0x01}, + {0x46, 0x01}, // PPC 96 kHz process flow value (its 48 kHz FIR flows write 0x11) {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}, // Page 1 @@ -25,6 +22,8 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = { static const LogString *model_name() { return LOG_STR("TAS5805M"); } +static const LogString *fault_output_name(uint8_t) { return LOG_STR("ADR"); } + // 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) @@ -88,6 +87,8 @@ const ModelInfo TAS5805M_MODEL = { NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING }, + .fault_output_pins = 1, // ADR + .fault_output_name = fault_output_name, }; } // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp index 61c8581eadf..a144710ae74 100644 --- a/esphome/components/tas58xx/model_tas5825m.cpp +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -6,7 +6,7 @@ namespace esphome::tas58xx { // Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page. // Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k". -// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet. +// Registers 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet. static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = { {0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from {0x7D, 0x11}, @@ -17,20 +17,25 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = { {0x1D, 0x00}, {0x19, 0x80}, {0x00, 0x00}, // Page 0 - {0x46, 0x11}, + {0x46, 0x11}, // DSP_CTRL: 96 kHz processing (bits 4-3 = 10), use default coefficients (bit 0 = 1) // PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML {0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz {0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z // Additional register configuration - // GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ - {0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ - {0x60, 0x02}, // GPIO_CTRL: GPIO1 is output {0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults }; static const LogString *model_name() { return LOG_STR("TAS5825M"); } +static const LogString *fault_output_name(uint8_t pin) { + if (pin == 0) + return LOG_STR("GPIO0"); + if (pin == 1) + return LOG_STR("GPIO1"); + return LOG_STR("GPIO2"); +} + // 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) @@ -113,6 +118,8 @@ const ModelInfo TAS5825M_MODEL = { // 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included // 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included }, + .fault_output_pins = 3, // GPIO0, GPIO1, GPIO2 (ADR is an analog input only on TAS5825M) + .fault_output_name = fault_output_name, }; } // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 3fd535b8d43..44827eaebbe 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -20,6 +20,7 @@ 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_1_1SPW = 0x01; // DAMP_MOD = 01 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 @@ -33,6 +34,9 @@ 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_GPIO_CTRL = 0x60; // ADR_PIN_CTRL on TAS5805M: output enable, bit n for pin n +static constexpr uint8_t TAS58XX_GPIO0_SEL = 0x61; // ADR_PIN_CONFIG on TAS5805M: pin n is selected at 0x61 + n +static constexpr uint8_t TAS58XX_GPIO_SEL_FAULTZ = 0x0B; // pin function: FAULTZ output 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; @@ -98,12 +102,26 @@ bool TAS58xx::init_() { return false; } - // Setters are public, so keep the value inside the register range + // The I2C address is latched at power up, so ADR can now be an output. The pin function is written before the + // output enable, so the pin never drives another function. + if (this->fault_output_pin_ != FAULT_OUTPUT_NONE) { + // The setter is public, so only write this model's pins; YAML only allows the pins of the model + if (this->fault_output_pin_ >= this->model_->fault_output_pins) { + ESP_LOGW(TAG, "Invalid fault output index %u", this->fault_output_pin_); + } else if (!this->write_byte(TAS58XX_GPIO0_SEL + this->fault_output_pin_, TAS58XX_GPIO_SEL_FAULTZ) || + !this->write_byte(TAS58XX_GPIO_CTRL, static_cast(1 << this->fault_output_pin_))) { + ESP_LOGE(TAG, "I2C write failed during init"); + return false; + } + } + + // Setters are public, so keep the values inside the register ranges 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_) || + const uint8_t device_ctrl_1 = (this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) | + (this->modulation_ == MODULATION_1SPW_MODE ? TAS58XX_DEVICE_CTRL_1_1SPW : 0); + if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, device_ctrl_1) || !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; @@ -263,6 +281,14 @@ void TAS58xx::dump_config() { } else { mixer_mode = LOG_STR("Stereo"); } + const LogString *fault_output; + if (this->fault_output_pin_ == FAULT_OUTPUT_NONE) { + fault_output = LOG_STR("None"); + } else if (this->fault_output_pin_ >= this->model_->fault_output_pins) { + fault_output = LOG_STR("Invalid"); + } else { + fault_output = this->model_->fault_output_name(this->fault_output_pin_); + } ESP_LOGCONFIG(TAG, "Audio Amplifier:\n" " Model: %s", @@ -273,11 +299,14 @@ void TAS58xx::dump_config() { ESP_LOGCONFIG(TAG, " Analog Gain: %.1f dB\n" " DAC Mode: %s\n" + " Modulation: %s\n" " Mixer Mode: %s\n" - " Volume Range: %.1f dB to %.1f dB", + " Volume Range: %.1f dB to %.1f dB\n" + " Fault Output: %s", 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_); + this->modulation_ == MODULATION_1SPW_MODE ? LOG_STR_LITERAL("1SPW") : LOG_STR_LITERAL("BD"), + LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_, LOG_STR_ARG(fault_output)); #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Any Fault", this->have_fault_binary_sensor_); for (auto *sensor : this->fault_binary_sensors_) { diff --git a/esphome/components/tas58xx/tas58xx.h b/esphome/components/tas58xx/tas58xx.h index 94e35294696..10639b7752a 100644 --- a/esphome/components/tas58xx/tas58xx.h +++ b/esphome/components/tas58xx/tas58xx.h @@ -22,6 +22,12 @@ enum DacMode : uint8_t { DAC_MODE_PBTL = 1, // Parallel bridge tied load, one speaker }; +/// DEVICE_CTRL_1 DAMP_MOD values, the same on all models +enum Modulation : uint8_t { + MODULATION_BD_MODE = 0, + MODULATION_1SPW_MODE = 1, +}; + enum MixerMode : uint8_t { MIXER_MODE_STEREO = 0, MIXER_MODE_STEREO_INVERSE, @@ -64,6 +70,8 @@ static_assert( /// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have. static constexpr uint8_t NO_BIT = 0xFF; +/// fault_output_pin_ value for no fault output +static constexpr uint8_t FAULT_OUTPUT_NONE = 0xFF; /// 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. @@ -91,6 +99,10 @@ struct ModelInfo { const LogString *(*fault_name)(uint8_t index); /// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT]; + /// Number of pins that can report FAULTZ: pin n is selected at 0x61 + n and enabled by bit n of 0x60 + uint8_t fault_output_pins; + /// Name of FAULTZ pin n, for dump_config + const LogString *(*fault_output_name)(uint8_t pin); }; #ifdef USE_TAS58XX_TAS5805M @@ -127,7 +139,10 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c: 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; } + /// pin: 0 to 2 for GPIOn on TAS5825M, 0 for ADR on TAS5805M; leave unset for no fault output + void set_fault_output(uint8_t pin) { this->fault_output_pin_ = pin; } void set_mixer_mode(MixerMode mixer_mode) { this->mixer_mode_ = mixer_mode; } + void set_modulation(Modulation modulation) { this->modulation_ = modulation; } 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; } @@ -171,7 +186,9 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c: 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}; + uint8_t fault_output_pin_{FAULT_OUTPUT_NONE}; // No fault output: the pin keeps its power on state MixerMode mixer_mode_{MIXER_MODE_STEREO}; + Modulation modulation_{MODULATION_BD_MODE}; 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 diff --git a/tests/component_tests/tas58xx/test_tas58xx.py b/tests/component_tests/tas58xx/test_tas58xx.py new file mode 100644 index 00000000000..56abb54daa5 --- /dev/null +++ b/tests/component_tests/tas58xx/test_tas58xx.py @@ -0,0 +1,31 @@ +"""Tests that the TAS58xx fault_output and modulation defaults keep the existing behaviour.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + + +def test_tas58xx_defaults_keep_the_fault_pin_and_bd_modulation( + generate_main: Callable[[str | Path], str], +) -> None: + """Without fault_output or modulation each model keeps its usual fault pin and BD modulation.""" + main_cpp = generate_main( + "tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml" + ) + + assert "amp_5805_default->set_fault_output(0);" in main_cpp + assert "amp_5825_default->set_fault_output(1);" in main_cpp + for amp in ("amp_5805_default", "amp_5825_default", "amp_5805_none"): + assert f"{amp}->set_modulation(tas58xx::MODULATION_BD_MODE);" in main_cpp + + +def test_tas58xx_fault_output_none_leaves_the_pin_alone( + generate_main: Callable[[str | Path], str], +) -> None: + """fault_output: none does not configure a fault pin.""" + main_cpp = generate_main( + "tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml" + ) + + assert "amp_5805_none->set_fault_output(" not in main_cpp diff --git a/tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml b/tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml new file mode 100644 index 00000000000..1aeb56fa2fd --- /dev/null +++ b/tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + id: i2c_bus + sda: 21 + scl: 22 + +audio_dac: + - platform: tas58xx + model: tas5805m + id: amp_5805_default + ignore_enable_pin_warning: true + - platform: tas58xx + model: tas5825m + id: amp_5825_default + ignore_enable_pin_warning: true + - platform: tas58xx + model: tas5805m + id: amp_5805_none + address: 0x2C + ignore_enable_pin_warning: true + fault_output: none diff --git a/tests/components/tas58xx/common.yaml b/tests/components/tas58xx/common.yaml index 60c0ba734a1..7686480c7ae 100644 --- a/tests/components/tas58xx/common.yaml +++ b/tests/components/tas58xx/common.yaml @@ -7,7 +7,9 @@ audio_dac: enable_pin: GPIO12 analog_gain: -15.5dB dac_mode: btl + fault_output: adr mixer_mode: stereo + modulation: 1SPW_MODE volume_min_db: -60dB volume_max_db: 0dB update_interval: 1s diff --git a/tests/components/tas58xx/validate.esp32-idf.yaml b/tests/components/tas58xx/validate.esp32-idf.yaml index 9a94137bbb2..a54525124ea 100644 --- a/tests/components/tas58xx/validate.esp32-idf.yaml +++ b/tests/components/tas58xx/validate.esp32-idf.yaml @@ -9,3 +9,4 @@ audio_dac: dac_mode: PBTL mixer_mode: mono analog_gain: -3.5dB + fault_output: none From 46d8c3ec0255d188fe029f5373ff158121a423de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Oct 2026 07:25:16 -1000 Subject: [PATCH 3/4] [core] Add publish="on_success" for Apply entities whose hub call can fail --- AGENTS.md | 3 +- esphome/core/entity_helpers.py | 11 +++++--- tests/unit_tests/core/test_entity_helpers.py | 29 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 39fb4cb0c9f..a4221f35671 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -443,7 +443,8 @@ file does, and it is the authority when they disagree. The most useful starting - **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or - `index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state. + `index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state, + and `publish="on_success"` publishes only when the hub call returns true (a write the device can refuse). `publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs the body only when turned on. Never use it for a new entity; an action is a button. ```python diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 791d8be792f..a0b752bd6e0 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -503,7 +503,7 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx -ApplyPublish = Literal["before", "after", "momentary"] | None +ApplyPublish = Literal["before", "after", "momentary", "on_success"] | None @dataclass(frozen=True, slots=True) @@ -577,7 +577,8 @@ class SubEntities: With ``apply`` the entity is the domain's ``Apply*`` class and its control runs ``parent->apply``, or ``apply`` as a namespaced free statement when no parent is bound, with - ``publish`` ("before"/"after") placing the domain's publish. ``publish="momentary"`` instead + ``publish`` ("before"/"after") placing the domain's publish; ``"on_success"`` publishes only when + ``apply`` returns true, for hubs whose write can fail. ``publish="momentary"`` instead acknowledges first (a switch publishes off) and runs ``apply`` only when turned on. It is only for legacy action switches; a new action entity is a button. """ @@ -600,9 +601,9 @@ class SubEntities: ) if publish is not None and (apply is None or self.apply_entity.publish is None): raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain") - if publish not in (None, "before", "after", "momentary"): + if publish not in (None, "before", "after", "momentary", "on_success"): raise ValueError( - f'{entity_id}: publish= must be "before", "after" or "momentary"' + f'{entity_id}: publish= must be "before", "after", "momentary" or "on_success"' ) if publish == "momentary" and self.apply_entity.momentary is None: raise ValueError(f'{entity_id}: publish="momentary" is not supported here') @@ -631,6 +632,8 @@ class SubEntities: body = [apply_entity.publish, call] elif publish == "after": body = [call, apply_entity.publish] + elif publish == "on_success": + body = [f"if ({call[:-1]}) {{ {apply_entity.publish} }}"] elif publish == "momentary": acknowledge, condition = apply_entity.momentary body = [acknowledge, f"if ({condition}) {{ {call} }}"] diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 3e02256cf9e..2450768f7cc 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1883,6 +1883,35 @@ async def test_sub_entities_apply_momentary( assert apply_functions[0][2] == body +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("parent", "apply", "body"), + [ + ( + _HUB, + "write_level(state)", + "if (::hub->write_level(state)) { entity->publish_state(state); }", + ), + ( + None, + "chip::write_level(state)", + "if (chip::write_level(state)) { entity->publish_state(state); }", + ), + ], +) +async def test_sub_entities_apply_on_success( + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + parent: MockObj | None, + apply: str, + body: str, +) -> None: + """publish="on_success" publishes only when the hub call returns true.""" + sub = _apply_sub([], {"k": _apply_conf("k_sw")}, parent=parent) + await sub("k", None, apply=apply, publish="on_success") + assert apply_functions[0][2] == body + + @pytest.mark.asyncio async def test_sub_entities_apply_without_parent_needs_namespaced_call( setup_test_environment: list[str], From c04cdea1a0224f79f5e181d1a28de709ea0342aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Oct 2026 07:27:55 -1000 Subject: [PATCH 4/4] [core] Build the on_success condition from the call expression and list the publish modes once --- esphome/core/entity_helpers.py | 18 +++++++++--------- tests/unit_tests/core/test_entity_helpers.py | 8 ++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index a0b752bd6e0..0b59c0e99f6 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -5,7 +5,7 @@ from importlib import import_module from importlib.util import find_spec import logging import re -from typing import Any, Literal +from typing import Any, Literal, get_args from esphome.automation import apply_static_function, parent_ref import esphome.codegen as cg @@ -503,7 +503,8 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx -ApplyPublish = Literal["before", "after", "momentary", "on_success"] | None +ApplyPublishMode = Literal["before", "after", "momentary", "on_success"] +ApplyPublish = ApplyPublishMode | None @dataclass(frozen=True, slots=True) @@ -578,7 +579,7 @@ class SubEntities: With ``apply`` the entity is the domain's ``Apply*`` class and its control runs ``parent->apply``, or ``apply`` as a namespaced free statement when no parent is bound, with ``publish`` ("before"/"after") placing the domain's publish; ``"on_success"`` publishes only when - ``apply`` returns true, for hubs whose write can fail. ``publish="momentary"`` instead + ``apply`` returns true. ``publish="momentary"`` instead acknowledges first (a switch publishes off) and runs ``apply`` only when turned on. It is only for legacy action switches; a new action entity is a button. """ @@ -601,10 +602,8 @@ class SubEntities: ) if publish is not None and (apply is None or self.apply_entity.publish is None): raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain") - if publish not in (None, "before", "after", "momentary", "on_success"): - raise ValueError( - f'{entity_id}: publish= must be "before", "after", "momentary" or "on_success"' - ) + if publish is not None and publish not in (modes := get_args(ApplyPublishMode)): + raise ValueError(f"{entity_id}: publish= must be one of {', '.join(modes)}") if publish == "momentary" and self.apply_entity.momentary is None: raise ValueError(f'{entity_id}: publish="momentary" is not supported here') @@ -627,13 +626,14 @@ class SubEntities: if isinstance(parent, ID): parent = await get_variable(parent) apply_entity = self.apply_entity - call = f"{apply};" if parent is None else f"{parent_ref(parent)}->{apply};" + expr = apply if parent is None else f"{parent_ref(parent)}->{apply}" + call = f"{expr};" if publish == "before": body = [apply_entity.publish, call] elif publish == "after": body = [call, apply_entity.publish] elif publish == "on_success": - body = [f"if ({call[:-1]}) {{ {apply_entity.publish} }}"] + body = [f"if ({expr}) {{ {apply_entity.publish} }}"] elif publish == "momentary": acknowledge, condition = apply_entity.momentary body = [acknowledge, f"if ({condition}) {{ {call} }}"] diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 2450768f7cc..b0e3c114171 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1711,6 +1711,14 @@ _SELECT_APPLY = entity_helpers.ApplyEntity( "after", "::hub->set_mode(index, options_);\nentity->publish_state(index);", ), + ( + "set_mode(option)", + "on_success", + ( + "const char *option = entity->option_at(index);\n" + "if (::hub->set_mode(option)) { entity->publish_state(index); }" + ), + ), ], ) async def test_sub_entities_apply_locals(