Compare commits

...
Author SHA1 Message Date
J. Nick Koston 9f9dfbf161 [text_sensor] Name the protected test helper with a trailing underscore 2026-10-26 22:03:08 -10:00
J. Nick Koston dc063c5d98 Merge branch 'dev' into text-sensor-throttle-timeout-filters 2026-10-26 08:24:53 -10:00
J. Nick Koston be585d8885 [text_sensor] Test that the validated default pair merges 2026-10-26 08:19:46 -10:00
J. Nick Koston d76c8b7de0 [text_sensor] Share the filter pair merge with sensor, keep build_filters plain and add timeout_throttle_filters 2026-10-26 08:12:19 -10:00
J. Nick Koston 38295397a4 [text_sensor] Name the timeout hook for clang-tidy and test the merged filter without priority values 2026-10-26 08:07:48 -10:00
Jonathan Swoboda 6c68985c14 [core] Report missing dependencies of a component as an import error (#21670) 2026-10-26 14:05:19 -04:00
Jonathan Swoboda 7be8a7c390 [core][esp32] Reject git refs and github:// shorthands with unmatched text (#21656) 2026-10-26 14:05:11 -04:00
J. Nick Koston f503b7d431 [text_sensor] Note the last keyword and why the timeout stops its loop before sending 2026-10-26 08:03:16 -10:00
J. Nick Koston 49e5e050df [text_sensor] Test that the timeout re-arms on each value and fires once 2026-10-26 08:02:15 -10:00
J. Nick Koston 4f717006fd [text_sensor] Merge a timeout and priority throttle with the same period into one filter 2026-10-26 07:59:17 -10:00
J. Nick Koston 7d3c5eea0e [text_sensor] Split the timeout filter by mode and read the loop clock 2026-10-26 07:46:16 -10:00
PolarGoose eb8cf139d7 [dlms_meter] Update parser to latest version to fix bogus error message on some smart meters (#21657) 2026-10-26 07:45:58 -10:00
J. Nick Koston b30ac789d1 [text_sensor] Send the timeout value without moving the stored text 2026-10-26 07:43:09 -10:00
J. Nick Koston 903c343bb8 [text_sensor] Add throttle, throttle_with_priority and timeout filters 2026-10-26 07:41:17 -10:00
Kevin Ahrendt ab6ebac628 [microphone] Zero muted audio in place before publishing (#21646) 2026-10-26 13:27:22 -04:00
6cb622417a [tas58xx] Add fault_output and modulation options (#21242)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-26 17:18:18 +00:00
Jonathan Swoboda 960316abd6 [core] Percent-encode "/" in git credentials (#21653) 2026-10-26 07:17:44 -10:00
Jonathan Swoboda 87341dc136 [logger] Enable Log V2 only on the native ESP-IDF toolchain (#21652) 2026-10-26 13:03:15 -04:00
38 changed files with 969 additions and 76 deletions

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ from esphome.cpp_helpers import ( # noqa: F401
add_variable,
build_registry_entry,
build_registry_list,
build_registry_list_merged,
extract_registry_entry_config,
get_slot_count,
gpio_pin_expression,
+1 -1
View File
@@ -22,7 +22,7 @@ CONF_CUSTOM_PATTERNS = "custom_patterns"
CONF_SKIP_CRC = "skip_crc"
CONF_DEFAULT_OBIS = "default_obis"
DLMS_PARSER_VERSION = "2.2.0"
DLMS_PARSER_VERSION = "2.2.2"
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
DlmsMeterComponent = dlms_meter_component_ns.class_(
+3 -1
View File
@@ -2389,7 +2389,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Schema(
{
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.git_ref,
cv.Optional(CONF_SOURCE): cv.All(
cv.string_strict, cv.Length(min=1)
),
cv.Optional(CONF_REF): cv.string,
cv.Optional(CONF_PATH): cv.string,
}
+4 -3
View File
@@ -607,9 +607,10 @@ async def _late_logger_init(config: ConfigType) -> None:
# esp_rom_printf instead of pulling ~1.2KB esp_rom_vprintf into IRAM.
# S2/C2 have a ROM vprintf, so IDF forces CONSTRAINED_ENV_SAFE=1 and
# cache-off DRAM logs would reach the flash-resident wrapper; keep V1.
if idf_version() >= cv.Version(6, 1, 0) and get_esp32_variant() not in (
VARIANT_ESP32C2,
VARIANT_ESP32S2,
if (
CORE.using_toolchain_esp_idf
and idf_version() >= cv.Version(6, 1, 0)
and get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32S2)
):
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_1", False)
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_2", True)
+19 -14
View File
@@ -4,6 +4,7 @@
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <type_traits>
#include <utility>
@@ -53,16 +54,25 @@ using DataCallbackManagerBase = CallbackManager<void(const AudioCapture &)>;
class DataCallbackManager : public DataCallbackManagerBase {
public:
explicit DataCallbackManager(const audio::AudioStreamInfo *stream_info) : stream_info_(stream_info) {}
DataCallbackManager(const audio::AudioStreamInfo *stream_info, const bool *mute_state)
: stream_info_(stream_info), mute_state_(mute_state) {}
using DataCallbackManagerBase::call;
// Remove before 2027.5.0, along with this class (use a plain CallbackManager)
ESPDEPRECATED("Use Microphone::publish_audio_() instead. Removed in 2027.5.0", "2026.11.0")
void call(const std::vector<uint8_t> &data) { this->call(AudioCapture{data, *this->stream_info_, 0}); }
void call(const std::vector<uint8_t> &data) {
if (*this->mute_state_) {
const std::vector<uint8_t> zeros(data.size(), 0);
this->call(AudioCapture{zeros, *this->stream_info_, 0});
} else {
this->call(AudioCapture{data, *this->stream_info_, 0});
}
}
protected:
const audio::AudioStreamInfo *stream_info_;
const bool *mute_state_;
};
class Microphone {
@@ -72,15 +82,7 @@ class Microphone {
/// Callbacks take (const AudioCapture &) and run on the microphone's task.
template<typename F> void add_data_callback(F &&data_callback) {
this->data_callbacks_.add(
[this, callback = as_capture_callback(std::forward<F>(data_callback))](const AudioCapture &capture) {
if (this->mute_state_) {
const std::vector<uint8_t> zeros(capture.data.size(), 0);
callback(AudioCapture{zeros, capture.stream_info, capture.capture_time_us});
} else {
callback(capture);
}
});
this->data_callbacks_.add(as_capture_callback(std::forward<F>(data_callback)));
}
bool is_running() const { return this->state_ == STATE_RUNNING; }
@@ -92,8 +94,11 @@ class Microphone {
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
protected:
void publish_audio_(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info,
int64_t capture_time_us = 0) {
/// While muted, data is zeroed first, so it must be a buffer the platform owns.
void publish_audio_(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us = 0) {
if (this->mute_state_) {
std::memset(data.data(), 0, data.size());
}
this->data_callbacks_.call(AudioCapture{data, stream_info, capture_time_us});
}
@@ -105,7 +110,7 @@ class Microphone {
audio::AudioStreamInfo audio_stream_info_;
// Make private when the deprecated call(data) is removed in 2027.5.0
DataCallbackManager data_callbacks_{&this->audio_stream_info_};
DataCallbackManager data_callbacks_{&this->audio_stream_info_, &this->mute_state_};
};
} // namespace esphome::microphone
@@ -90,6 +90,7 @@ bool ResamplerMicrophone::init_resampler_(const audio::AudioStreamInfo &input_st
if (input_stream_info.get_sample_rate() == this->target_sample_rate_) {
// The source already delivers the target sample rate, so its audio is passed through unchanged
this->output_buffer_.reserve(input_stream_info.ms_to_bytes(BUFFER_DURATION_MS));
this->input_stream_info_ = input_stream_info;
this->resampler_ready_ = true;
return true;
@@ -137,7 +138,23 @@ void ResamplerMicrophone::process_audio_(const microphone::AudioCapture &capture
}
if (this->resampler_ == nullptr) {
this->publish_audio_(capture.data, input_stream_info, capture.capture_time_us);
if (this->mute_state_) {
// The source's buffer is shared with its other consumers, so publish zeros from our own buffer, in pieces that
// fit its reserved size so muting never allocates
const uint32_t max_frames = input_stream_info.ms_to_frames(BUFFER_DURATION_MS);
const uint32_t total_frames = input_stream_info.bytes_to_frames(capture.data.size());
for (uint32_t done = 0; done < total_frames;) {
const uint32_t frames = std::min(total_frames - done, max_frames);
this->output_buffer_.assign(input_stream_info.frames_to_bytes(frames), 0);
const int64_t time_us =
capture.capture_time_us == 0 ? 0 : capture.capture_time_us + input_stream_info.frames_to_microseconds(done);
this->publish_audio_(this->output_buffer_, input_stream_info, time_us);
done += frames;
}
} else {
// Needs a protected forwarding method once data_callbacks_ is made private in 2027.5.0
this->data_callbacks_.call(microphone::AudioCapture{capture.data, input_stream_info, capture.capture_time_us});
}
return;
}
+9 -19
View File
@@ -45,7 +45,6 @@ from esphome.const import (
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_FAHRENHEIT,
CONF_VALUE,
@@ -1009,26 +1008,17 @@ def _timeout_throttle_period(
return timeout[CONF_TIMEOUT]
def _timeout_throttle_merge(first: ConfigType, second: ConfigType) -> tuple | None:
if (period := _timeout_throttle_period(first, second)) is None:
return None
return TimeoutThrottleFilter, [period]
async def _build_chain_filters(config: list[ConfigType]) -> list:
"""Like build_filters, but merges the radar `timeout` + `throttle_with_priority` pair into one filter."""
filters = []
i = 0
while i < len(config):
if (
i + 1 < len(config)
and (period := _timeout_throttle_period(config[i], config[i + 1]))
is not None
):
filter_id = config[i][CONF_TYPE_ID].copy()
filter_id.type = TimeoutThrottleFilter
var = cg.new_Pvariable(filter_id, period)
await cg.register_component(var, {})
filters.append(var)
i += 2
continue
filters.append(await cg.build_registry_entry(FILTER_REGISTRY, config[i]))
i += 1
return filters
return await cg.build_registry_list_merged(
FILTER_REGISTRY, config, _timeout_throttle_merge
)
_CALLBACK_AUTOMATIONS = (
+23
View File
@@ -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))
@@ -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
+12 -5
View File
@@ -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
+35 -6
View File
@@ -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<uint8_t>(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<uint8_t>(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_) {
+17
View File
@@ -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
+115 -1
View File
@@ -13,7 +13,9 @@ from esphome.const import (
CONF_ON_RAW_VALUE,
CONF_ON_VALUE,
CONF_STATE,
CONF_TIMEOUT,
CONF_TO,
CONF_VALUE,
DEVICE_CLASS_DATE,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_TIMESTAMP,
@@ -59,6 +61,18 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter)
PrependFilter = text_sensor_ns.class_("PrependFilter", Filter)
SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter)
MapFilter = text_sensor_ns.class_("MapFilter", Filter)
ThrottleFilter = text_sensor_ns.class_("ThrottleFilter", Filter)
ThrottleWithPriorityFilter = text_sensor_ns.class_(
"ThrottleWithPriorityFilter", ThrottleFilter
)
TimeoutFilterBase = text_sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
TimeoutFilterLast = text_sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
TimeoutFilterConfigured = text_sensor_ns.class_(
"TimeoutFilterConfigured", TimeoutFilterBase
)
TimeoutThrottleFilter = text_sensor_ns.class_(
"TimeoutThrottleFilter", TimeoutFilterLast
)
Substitution = text_sensor_ns.struct("Substitution")
@@ -128,6 +142,80 @@ async def substitution_filter_to_code(
return cg.new_Pvariable(filter_id, _substitution_table(config))
@FILTER_REGISTRY.register(
"throttle", ThrottleFilter, cv.positive_time_period_milliseconds
)
async def throttle_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
return cg.new_Pvariable(filter_id, config)
THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value(
{
cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds,
cv.Optional(CONF_VALUE, default=[]): cv.ensure_list(cv.string),
},
key=CONF_TIMEOUT,
)
@FILTER_REGISTRY.register(
"throttle_with_priority", ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA
)
async def throttle_with_priority_filter_to_code(
config: ConfigType, filter_id: ID
) -> MockObj:
if not (values := config[CONF_VALUE]):
filter_id = filter_id.copy()
filter_id.type = ThrottleFilter
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT])
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], _priority_table(values))
def _priority_table(values: list[str]) -> MockObj:
"""Shared PROGMEM list of priority texts, ended by nullptr; nullptr without any."""
if not values:
return cg.nullptr
return cg.shared_progmem_array(
"text_sensor_priorities",
cg.const_char_ptr,
cg.ArrayInitializer(*values, cg.nullptr),
)
def timeout_throttle_filters(priority: str, period_ms: int = 1000) -> list[ConfigType]:
"""Default filters for a text entity: resend the last value after a pause, at most one value a period,
`priority` at once. Built as one filter, like sensor.TIMEOUT_THROTTLE_FILTERS."""
period = cv.TimePeriod(milliseconds=period_ms)
return [
{"timeout": {CONF_TIMEOUT: period, CONF_VALUE: "last"}},
{"throttle_with_priority": {CONF_TIMEOUT: period, CONF_VALUE: [priority]}},
]
TIMEOUT_SCHEMA = cv.maybe_simple_value(
{
cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds,
cv.Optional(CONF_VALUE, default="last"): cv.string,
},
key=CONF_TIMEOUT,
)
@FILTER_REGISTRY.register("timeout", TimeoutFilterBase, TIMEOUT_SCHEMA)
async def timeout_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
filter_id = filter_id.copy()
args = [config[CONF_TIMEOUT]]
# "last" is a keyword, so a timeout cannot send that literal text
if (value := config[CONF_VALUE]) == "last":
filter_id.type = TimeoutFilterLast
else:
filter_id.type = TimeoutFilterConfigured
args.append(value)
var = cg.new_Pvariable(filter_id, *args)
await cg.register_component(var, {})
return var
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
_TEXT_SENSOR_SCHEMA = (
@@ -184,6 +272,32 @@ async def build_filters(config):
return await cg.build_registry_list(FILTER_REGISTRY, config)
def _timeout_throttle_pair(first: ConfigType, second: ConfigType) -> tuple | None:
"""Period and priority texts of a `timeout` (value `last`) directly followed by a
`throttle_with_priority` with the same period, or None."""
if (timeout := first.get(CONF_TIMEOUT)) is None or (
throttle := second.get("throttle_with_priority")
) is None:
return None
if timeout[CONF_VALUE] != "last" or timeout[CONF_TIMEOUT] != throttle[CONF_TIMEOUT]:
return None
return timeout[CONF_TIMEOUT], throttle[CONF_VALUE]
def _timeout_throttle_merge(first: ConfigType, second: ConfigType) -> tuple | None:
if (pair := _timeout_throttle_pair(first, second)) is None:
return None
period, values = pair
return TimeoutThrottleFilter, [period, _priority_table(values)]
async def _build_chain_filters(config: list[ConfigType]) -> list:
"""Like build_filters, but merges a `timeout` + `throttle_with_priority` pair into one filter."""
return await cg.build_registry_list_merged(
FILTER_REGISTRY, config, _timeout_throttle_merge
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_VALUE, "add_on_state_callback", [(cg.std_string, "x")]
@@ -205,7 +319,7 @@ async def setup_text_sensor_core_(var, config):
if config.get(CONF_FILTERS): # must exist and not be empty
cg.add_define("USE_TEXT_SENSOR_FILTER")
filters = await build_filters(config[CONF_FILTERS])
filters = await _build_chain_filters(config[CONF_FILTERS])
for filter_ in filters:
cg.add(var.add_filter(filter_))
+60
View File
@@ -3,6 +3,7 @@
#include "filter.h"
#include "text_sensor.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
@@ -98,6 +99,65 @@ bool MapFilter::new_value(std::string &value) {
return true;
}
// Throttle
// True when a value may pass: the period is over, or the value is in the nullptr-ended `prioritized` list
static bool throttle_passes(uint32_t &last_input, uint32_t period, const std::string &value,
const char *const *prioritized) {
const uint32_t now = App.get_loop_component_start_time();
bool pass = last_input == 0 || now - last_input >= period;
for (const char *const *p = prioritized; !pass && p != nullptr && *p != nullptr; p++)
pass = value == *p;
if (pass)
last_input = now;
return pass;
}
bool ThrottleFilter::new_value(std::string &value) {
return throttle_passes(this->last_input_, this->min_time_between_inputs_, value, nullptr);
}
bool ThrottleWithPriorityFilter::new_value(std::string &value) {
return throttle_passes(this->last_input_, this->min_time_between_inputs_, value, this->prioritized_);
}
// Timeout
bool TimeoutFilterBase::new_value(std::string &value) {
this->timeout_start_time_ = App.get_loop_component_start_time();
this->enable_loop();
return true;
}
void TimeoutFilterBase::loop() {
if (App.get_loop_component_start_time() - this->timeout_start_time_ < this->time_period_)
return;
// Before sending, so a callback that publishes to this sensor again re-arms the timeout
this->disable_loop();
this->send_timeout_value();
}
void TimeoutFilterBase::send_(const char *text, size_t len) {
if (this->next_ == nullptr) {
this->parent_->internal_send_state_to_frontend(text, len);
} else {
this->next_->input(std::string(text, len));
}
}
bool TimeoutFilterLast::new_value(std::string &value) {
this->last_ = value; // reuses last_'s buffer
return TimeoutFilterBase::new_value(value);
}
bool TimeoutThrottleFilter::new_value(std::string &value) {
TimeoutFilterLast::new_value(value);
return throttle_passes(this->last_input_, this->time_period_, value, this->prioritized_);
}
void TimeoutThrottleFilter::send_timeout_value() {
if (throttle_passes(this->last_input_, this->time_period_, this->last_, this->prioritized_))
this->send_(this->last_.data(), this->last_.size());
}
} // namespace esphome::text_sensor
#endif // USE_TEXT_SENSOR_FILTER
+76
View File
@@ -169,6 +169,82 @@ class MapFilter : public Filter {
const Substitution *mappings_;
};
/// Drops a value that comes sooner than `min_time_between_inputs` after the last one let through.
class ThrottleFilter : public Filter {
public:
explicit ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {}
bool new_value(std::string &value) override;
protected:
uint32_t last_input_{0};
uint32_t min_time_between_inputs_;
};
/// Same as ThrottleFilter, but a value in `prioritized` always passes. `prioritized` is a codegen table
/// in flash, shared by filters with the same list and ended by nullptr.
class ThrottleWithPriorityFilter : public ThrottleFilter {
public:
ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const char *const *prioritized)
: ThrottleFilter(min_time_between_inputs), prioritized_(prioritized) {}
bool new_value(std::string &value) override;
protected:
const char *const *prioritized_;
};
/// Passes every value on and, once none has come for `time_period`, sends one value again. A Component rather than
/// a scheduler timeout for the reason given on the sensor's TimeoutFilterBase: it re-arms on every value.
class TimeoutFilterBase : public Filter, public Component {
public:
bool new_value(std::string &value) override;
void loop() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
protected:
explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); }
/// Sends `text`; never moves a stored string on, so it keeps its buffer.
void send_(const char *text, size_t len);
virtual void send_timeout_value() = 0;
uint32_t time_period_;
uint32_t timeout_start_time_{0};
};
/// Sends the last value again.
class TimeoutFilterLast : public TimeoutFilterBase {
public:
explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {}
bool new_value(std::string &value) override;
protected:
void send_timeout_value() override { this->send_(this->last_.data(), this->last_.size()); }
std::string last_;
};
/// Codegen use only: `timeout` (value `last`) followed by `throttle_with_priority` with the same period, as one
/// object. Output matches the two filter chain exactly.
class TimeoutThrottleFilter : public TimeoutFilterLast {
public:
TimeoutThrottleFilter(uint32_t time_period, const char *const *prioritized)
: TimeoutFilterLast(time_period), prioritized_(prioritized) {}
bool new_value(std::string &value) override;
protected:
void send_timeout_value() override;
const char *const *prioritized_;
uint32_t last_input_{0}; // When the throttle stage last let a value through
};
/// Sends the configured text.
class TimeoutFilterConfigured : public TimeoutFilterBase {
public:
TimeoutFilterConfigured(uint32_t time_period, const char *value) : TimeoutFilterBase(time_period), value_(value) {}
protected:
void send_timeout_value() override { this->send_(this->value_, strlen(this->value_)); }
const char *value_;
};
} // namespace esphome::text_sensor
#endif // USE_TEXT_SENSOR_FILTER
+9 -5
View File
@@ -2744,8 +2744,13 @@ def url(value):
raise Invalid("Expected a file scheme or a URL scheme with host")
def git_ref(value):
if re.match(r"[a-zA-Z0-9_./-]+", value) is None:
# Characters git never allows in a branch or tag name (git check-ref-format)
GIT_REF_PATTERN = r"[^\s~^:?*\[\\]+"
def git_ref(value: typing.Any) -> str:
value = string_strict(value)
if re.fullmatch(GIT_REF_PATTERN, value) is None:
raise Invalid("Not a valid git ref")
return value
@@ -3000,9 +3005,8 @@ def validate_source_shorthand(value):
except Invalid:
pass
# Regex for GitHub repo name with optional branch/tag
# Note: git allows other branch/tag names as well, but never seen them used before
m = re.match(
r"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@([a-zA-Z0-9\-_.\./]+))?|pr#([0-9]+))",
m = re.fullmatch(
rf"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@({GIT_REF_PATTERN}))?|pr#([0-9]+))",
value,
)
if m is None:
+25
View File
@@ -372,6 +372,31 @@ async def build_registry_list(registry, config):
return actions
async def build_registry_list_merged(
registry: Registry,
config: list[ConfigType],
merge: Callable[[ConfigType, ConfigType], tuple[Any, list] | None],
) -> list[MockObj]:
"""Like build_registry_list, but two neighbouring entries that ``merge`` accepts are built as one Component.
``merge(first, second)`` returns the merged class and its constructor arguments, or None.
"""
built = []
i = 0
while i < len(config):
if i + 1 < len(config) and (merged := merge(config[i], config[i + 1])):
type_id = config[i][CONF_TYPE_ID].copy()
type_id.type, args = merged
var = new_Pvariable(type_id, *args)
await register_component(var, {})
built.append(var)
i += 2
continue
built.append(await build_registry_entry(registry, config[i]))
i += 1
return built
async def past_safe_mode():
if CONF_SAFE_MODE not in CORE.config:
return None
+5 -5
View File
@@ -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)
@@ -1051,8 +1051,8 @@ class GitFile:
"""Parse a git shorthand URL into its components."""
if not isinstance(shorthand, str):
raise ValueError("Git shorthand must be a string")
m = re.match(
r"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>[a-zA-Z0-9\-_.\./]+))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
m = re.fullmatch(
rf"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>{cv.GIT_REF_PATTERN}))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
shorthand,
)
if m is None:
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies:
bblanchon/arduinojson:
version: "7.4.3"
esphome/dlms_parser:
version: 2.2.0
version: 2.2.2
esphome/esp-audio-libs:
version: 4.1.0
esphome/esp-micro-speech-features:
+7 -2
View File
@@ -284,12 +284,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
_COMPONENT_CACHE[domain] = manif
return manif
module_name = f"esphome.components.{domain}"
try:
module = importlib.import_module(f"esphome.components.{domain}")
module = importlib.import_module(module_name)
except ImportError as e:
if exception:
raise
if "No module named" in str(e):
if (
isinstance(e, ModuleNotFoundError)
and e.name is not None
and f"{module_name}.".startswith(f"{e.name}.")
):
_LOGGER.info(
"Unable to import component %s: %s", domain, str(e), exc_info=False
)
+1 -1
View File
@@ -106,7 +106,7 @@ build_unflags =
; esp32 envs (which would double-include them).
[common:idf-component-libs]
lib_deps =
esphome/dlms_parser@2.2.0 ; dlms_meter
esphome/dlms_parser@2.2.2 ; dlms_meter
bblanchon/ArduinoJson@7.4.3 ; json
lvgl/lvgl@9.6.0 ; lvgl
@@ -138,3 +138,26 @@ def test_task_log_buffer_size_unaligned_allowed_on_libretiny(
) -> None:
set_core_config(PlatformFramework.BK72XX_ARDUINO)
assert validate_task_log_buffer_alignment(641) == 641
@pytest.mark.parametrize(
("config", "log_v2"),
[
pytest.param("test_logger_idf6_esp_idf.yaml", True, id="native"),
pytest.param("test_logger_idf6_platformio.yaml", False, id="platformio"),
],
)
def test_log_v2_only_on_native_toolchain(
generate_main: Callable[[str | Path], str],
config: str,
log_v2: bool,
) -> None:
"""PlatformIO links the bootloader with the app's build flags, so the wrap has no target there."""
from esphome.components.esp32 import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
generate_main(f"tests/component_tests/logger/{config}")
assert ("-Wl,--wrap=esp_log_format" in CORE.build_flags) is log_v2
assert ("USE_ESP32_LOG_V2" in {d.name for d in CORE.defines}) is log_v2
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert ("CONFIG_LOG_VERSION_2" in sdkconfig) is log_v2
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
toolchain: esp-idf
framework:
type: esp-idf
version: 6.1.0
logger:
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: esp32dev
toolchain: platformio
framework:
type: esp-idf
version: 6.1.0
platform_version: 61.4.0-RC1
logger:
@@ -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
@@ -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
@@ -100,3 +100,43 @@ def test_substitution_table_names_avoid_user_ids(
assert len(tables) == 2
assert "text_sensor_substitutions" not in tables
assert "text_sensor_mapfilter_id_table" not in tables
def test_throttle_and_timeout_filters(generate_main: Callable[[str], str]) -> None:
"""Priority values go in a PROGMEM table ended by nullptr; no priority is a plain throttle."""
main_cpp = generate_main(
"tests/component_tests/text_sensor/test_text_sensor_throttle_timeout.yaml"
)
table = re.search(
r'static constexpr const char \* (\w+)\[\] PROGMEM = \{"NA", nullptr\}',
main_cpp,
)
assert table is not None
# timeout (last) then throttle_with_priority with the same period merge into one filter
assert f"text_sensor::TimeoutThrottleFilter(1000, {table.group(1)})" in main_cpp
alone = re.search(
r'static constexpr const char \* (\w+)\[\] PROGMEM = \{"Off", "NA", nullptr\}',
main_cpp,
)
assert alone is not None
assert f"text_sensor::ThrottleWithPriorityFilter(300, {alone.group(1)})" in main_cpp
assert "text_sensor::ThrottleFilter(500)" in main_cpp
assert "text_sensor::TimeoutThrottleFilter(2000, nullptr)" in main_cpp
assert 'text_sensor::TimeoutFilterConfigured(30000, "Offline")' in main_cpp
def test_timeout_throttle_filters_default_merges() -> None:
"""The validated default is a pair the chain builder merges into one filter."""
from esphome.components.text_sensor import (
_timeout_throttle_pair,
timeout_throttle_filters,
validate_filters,
)
first, second = validate_filters(timeout_throttle_filters("NA"))
pair = _timeout_throttle_pair(first, second)
assert pair is not None
period, values = pair
assert period.total_milliseconds == 1000
assert values == ["NA"]
@@ -0,0 +1,35 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
text_sensor:
- platform: template
id: ts_position
filters:
- timeout: 1s
- throttle_with_priority:
timeout: 1s
value: NA
- platform: template
id: ts_plain
filters:
- throttle_with_priority: 500ms
- timeout:
timeout: 30s
value: Offline
- platform: template
id: ts_merged_plain
filters:
- timeout: 2s
- throttle_with_priority: 2s
- platform: template
id: ts_priority_alone
filters:
- throttle_with_priority:
timeout: 300ms
value:
- "Off"
- NA
@@ -17,7 +17,7 @@ class TestMicrophone : public Microphone {
void start() override {}
void stop() override {}
void publish(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
void publish(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
this->publish_audio_(data, stream_info, capture_time_us);
}
bool has_callbacks() const { return this->has_data_callbacks_(); }
@@ -56,20 +56,24 @@ TEST(MicrophoneDataCallbacks, CallbackGetsDataFormatAndCaptureTime) {
});
EXPECT_TRUE(mic.has_callbacks());
mic.publish(SAMPLES, STEREO_48K, 123456);
std::vector<uint8_t> buffer = SAMPLES;
mic.publish(buffer, STEREO_48K, 123456);
EXPECT_EQ(data, SAMPLES);
EXPECT_EQ(stream_info, STEREO_48K);
EXPECT_EQ(capture_time_us, 123456);
}
TEST(MicrophoneDataCallbacks, MutedCallbackGetsZeros) {
TEST(MicrophoneDataCallbacks, MuteZeroesTheBufferInPlace) {
TestMicrophone mic;
std::vector<uint8_t> data;
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
mic.set_mute_state(true);
mic.publish(SAMPLES, STEREO_48K, 0);
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
std::vector<uint8_t> buffer = SAMPLES;
mic.publish(buffer, STEREO_48K, 0);
const std::vector<uint8_t> zeros(SAMPLES.size(), 0);
EXPECT_EQ(data, zeros);
EXPECT_EQ(buffer, zeros);
}
TEST(MicrophoneDataCallbacks, DataTriggerPassesACopy) {
@@ -115,4 +119,17 @@ TEST(MicrophoneDataCallbacks, DeprecatedCallUsesMicrophoneFormat) {
EXPECT_EQ(capture_time_us, 0);
}
// Remove in 2027.5.0 with the deprecated DataCallbackManager::call(data)
TEST(MicrophoneDataCallbacks, DeprecatedCallDeliversZerosWhileMuted) {
TestMicrophone mic;
std::vector<uint8_t> data;
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
mic.set_mute_state(true);
const std::vector<uint8_t> buffer = SAMPLES;
mic.publish_deprecated(buffer);
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
EXPECT_EQ(buffer, SAMPLES);
}
} // namespace esphome::microphone::testing
+2
View File
@@ -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
@@ -9,3 +9,4 @@ audio_dac:
dac_mode: PBTL
mixer_mode: mono
analog_gain: -3.5dB
fault_output: none
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# filter.cpp only compiles with the define the real to_code emits for configured filters
cg.add_define("USE_TEXT_SENSOR_FILTER")
manifest.to_code = to_code_testing
+23
View File
@@ -30,6 +30,29 @@ text_sensor:
- substitute:
- BAR -> baz
- platform: template
name: "Test Throttle"
id: test_throttle
filters:
- throttle: 1s
- platform: template
name: "Test Throttle With Priority"
id: test_throttle_with_priority
filters:
- timeout: 1s
- throttle_with_priority:
timeout: 1s
value: NA
- platform: template
name: "Test Timeout Value"
id: test_timeout_value
filters:
- timeout:
timeout: 30s
value: Offline
- platform: template
name: "Test Map Single"
id: test_map_single
@@ -0,0 +1,114 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "esphome/components/text_sensor/filter.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/application.h"
namespace esphome::text_sensor::testing {
class TextFilterTest : public ::testing::Test {
protected:
void SetUp() override {
this->sensor_.add_on_state_callback([this](const std::string &state) { this->seen_.push_back(state); });
}
void publish_at_(uint32_t now, const char *value) {
LoopBlockingGuard guard{nullptr, nullptr, now};
this->sensor_.publish_state(value);
}
static void loop_at(Component &filter, uint32_t now) {
LoopBlockingGuard guard{nullptr, nullptr, now};
filter.loop();
}
TextSensor sensor_;
std::vector<std::string> seen_;
};
static constexpr const char *PRIORITIZED[] = {"NA", nullptr};
TEST_F(TextFilterTest, ThrottleDropsValuesInsideThePeriod) {
ThrottleFilter throttle(1000);
this->sensor_.add_filter(&throttle);
this->publish_at_(1000, "a");
this->publish_at_(1500, "b");
this->publish_at_(2000, "c");
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "c"}));
}
TEST_F(TextFilterTest, PrioritizedValuePassesAtOnce) {
ThrottleWithPriorityFilter throttle(1000, PRIORITIZED);
this->sensor_.add_filter(&throttle);
this->publish_at_(1000, "a");
this->publish_at_(1100, "NA");
this->publish_at_(1200, "b");
this->publish_at_(2100, "c");
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "NA", "c"}));
}
TEST_F(TextFilterTest, TimeoutRearmsOnEachValueAndFiresOnce) {
TimeoutFilterLast timeout(1000);
this->sensor_.add_filter(&timeout);
this->publish_at_(1000, "a");
this->publish_at_(1800, "b");
loop_at(timeout, 2500);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "b"}));
EXPECT_FALSE(timeout.is_idle());
loop_at(timeout, 2800);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "b", "b"}));
EXPECT_TRUE(timeout.is_idle());
}
TEST_F(TextFilterTest, TimeoutSendsTheConfiguredValue) {
TimeoutFilterConfigured timeout(1000, "Offline");
this->sensor_.add_filter(&timeout);
this->publish_at_(1000, "a");
loop_at(timeout, 2000);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "Offline"}));
}
// Feeds the same values to the timeout + throttle chain and to the merged filter
static std::vector<std::string> run_sequence(std::initializer_list<Filter *> filters, Component &timeout) {
TextSensor sensor;
std::vector<std::string> seen;
sensor.add_on_state_callback([&seen](const std::string &state) { seen.push_back(state); });
sensor.add_filters(filters);
const auto publish_at = [&sensor](uint32_t now, const char *value) {
LoopBlockingGuard guard{nullptr, nullptr, now};
sensor.publish_state(value);
};
const auto loop_at = [&timeout](uint32_t now) {
LoopBlockingGuard guard{nullptr, nullptr, now};
timeout.loop();
};
publish_at(1000, "a");
publish_at(1200, "b"); // held by the throttle, sent once updates pause
loop_at(2200);
publish_at(2300, "c");
publish_at(2400, "NA"); // a priority value passes at once
publish_at(2500, "d");
loop_at(3500);
return seen;
}
TEST(TextFilterChain, CombinedTimeoutThrottleMatchesTheChain) {
TimeoutFilterLast timeout(1000);
ThrottleWithPriorityFilter throttle(1000, PRIORITIZED);
const auto chain = run_sequence({&timeout, &throttle}, timeout);
TimeoutThrottleFilter combined(1000, PRIORITIZED);
EXPECT_EQ(run_sequence({&combined}, combined), chain);
EXPECT_EQ(chain, (std::vector<std::string>{"a", "b", "NA", "d"}));
}
TEST_F(TextFilterTest, CombinedWithoutPriorityIsAPlainThrottle) {
TimeoutThrottleFilter combined(1000, nullptr);
this->sensor_.add_filter(&combined);
this->publish_at_(1000, "a");
this->publish_at_(1300, "NA");
loop_at(combined, 2300);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "NA"}));
}
} // namespace esphome::text_sensor::testing
@@ -0,0 +1,28 @@
"""Tests for the esp32 framework ``components:`` schema."""
import pytest
from esphome.components import esp32
import esphome.config_validation as cv
@pytest.mark.parametrize(
"source",
[
"https://github.com/owner/component.git",
"git@github.com:owner/component.git",
],
)
def test_component_source_accepts_repository_url(source: str) -> None:
config = esp32.FRAMEWORK_SCHEMA(
{"components": [{"name": "owner/component", "source": source}]}
)
assert config["components"][0]["source"] == source
@pytest.mark.parametrize("source", [1.0, ""])
def test_component_source_rejects_invalid(source: object) -> None:
with pytest.raises(cv.Invalid):
esp32.FRAMEWORK_SCHEMA(
{"components": [{"name": "owner/component", "source": source}]}
)
+42 -2
View File
@@ -3121,9 +3121,21 @@ def test_git_ref_valid() -> None:
assert cv.git_ref("v1.2.3") == "v1.2.3"
def test_git_ref_invalid() -> None:
@pytest.mark.parametrize("ref", ["main", "release/v6.0", "feat+x", "a1b2c3d4"])
def test_git_ref_valid_chars(ref: str) -> None:
assert cv.git_ref(ref) == ref
@pytest.mark.parametrize("ref", ["main hi there", "a:b", "x~1", "", "main\n"])
def test_git_ref_invalid(ref: str) -> None:
with pytest.raises(Invalid, match="Not a valid git ref"):
cv.git_ref("!!!")
cv.git_ref(ref)
@pytest.mark.parametrize("ref", [1.0, 2025, None])
def test_git_ref_not_string(ref: object) -> None:
with pytest.raises(Invalid, match="Must be string"):
cv.git_ref(ref)
def test_source_refresh_always() -> None:
@@ -3291,6 +3303,34 @@ def test_validate_source_shorthand_github_pr(setup_core: Path) -> None:
assert result[CONF_REF] == "pull/1234/head"
def test_validate_source_shorthand_github_ref_with_slash(setup_core: Path) -> None:
result = cv.validate_source_shorthand("github://user/repo@release/v1")
assert result[CONF_REF] == "release/v1"
def test_validate_source_shorthand_github_ref_keeps_all_chars(
setup_core: Path,
) -> None:
result = cv.validate_source_shorthand("github://user/repo@feat+x")
assert result[CONF_REF] == "feat+x"
@pytest.mark.parametrize(
"value",
[
"github://esphome/esphome/esphome/components@dev",
"github://user/repo/",
"github://user/repo@main hi",
"github://pr#1234abc",
],
)
def test_validate_source_shorthand_rejects_trailing_text(
setup_core: Path, value: str
) -> None:
with pytest.raises(Invalid, match="not a file system path"):
cv.validate_source_shorthand(value)
def test_validate_source_shorthand_invalid(setup_core: Path) -> None:
with pytest.raises(Invalid, match="not a file system path"):
cv.validate_source_shorthand("notvalid")
+63
View File
@@ -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:
@@ -3289,6 +3332,26 @@ def test_git_file_from_shorthand_without_ref_or_query() -> None:
assert gf == git.GitFile("codeberg.org", "owner", "repo", "file.yaml")
def test_git_file_from_shorthand_keeps_whole_ref() -> None:
gf = git.GitFile.from_shorthand("github://owner/repo/file.yaml@feat+x?full_config")
assert gf.ref == "feat+x"
assert gf.query == "full_config"
@pytest.mark.parametrize(
"shorthand",
[
"github://owner/repo/file.yaml@main hi",
"github://owner/repo/my file.yaml",
"github://owner/repo/file.yaml?full config",
],
)
def test_git_file_from_shorthand_rejects_trailing_text(shorthand: str) -> None:
with pytest.raises(ValueError, match="not in expected"):
git.GitFile.from_shorthand(shorthand)
def test_git_file_from_shorthand_requires_string() -> None:
with pytest.raises(ValueError, match="must be a string"):
git.GitFile.from_shorthand(123)
+39
View File
@@ -26,6 +26,7 @@ from esphome.loader import (
clear_component_meta_finders,
get_alias_metadata,
get_component,
get_platform,
install_meta_finder,
)
from tests.testing_helpers import ComponentManifestOverride
@@ -809,6 +810,44 @@ def test_lookup_module_import_error_missing_module_returns_none(
assert "definitely_missing_component_xyz" not in _COMPONENT_CACHE
def test_lookup_module_missing_platform_returns_none(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A platform file that does not exist is "not found", not an error."""
with caplog.at_level(logging.INFO, logger="esphome.loader"):
assert get_platform("definitely_missing_platform_xyz", "template") is None
assert "Unable to import component template.definitely_missing_platform_xyz" in (
caplog.text
)
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
def test_lookup_module_missing_dependency_is_logged_as_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A component that exists but imports a missing package reports that
package as an error, instead of looking like a missing component."""
comp = tmp_path / "depfailcomp"
comp.mkdir()
(comp / "__init__.py").write_text("import definitely_missing_package_xyz\n")
saved = list(sys.meta_path)
try:
install_meta_finder(tmp_path)
with caplog.at_level(logging.INFO, logger="esphome.loader"):
assert get_component("depfailcomp") is None
finally:
sys.meta_path[:] = saved
for name in [
m for m in sys.modules if m.startswith("esphome.components.depfailcomp")
]:
del sys.modules[name]
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1
assert errors[0].getMessage() == "Unable to import component depfailcomp:"
assert errors[0].exc_info is not None
assert errors[0].exc_info[1].name == "definitely_missing_package_xyz"
def test_lookup_module_missing_module_raises_when_requested() -> None:
"""With ``exception=True`` the ImportError propagates."""
with pytest.raises(ImportError):