Compare commits

...
Author SHA1 Message Date
Kevin Ahrendt 63ef2b9b81 [sendspin] Add the source role, streaming a microphone to the server
The server starts and stops the stream; the microphone runs only while it is
open. Each buffer is passed to sendspin-cpp with its capture time. Audio is
sent as PCM.
2026-10-26 16:40:28 -04:00
J. Nick Koston efba19ea3a [ld2420] Switch the module to simple reporting when the select picks Simple (#21651) 2026-10-26 08:29:45 -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
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
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
J. Nick Koston cfe5809654 [wifi] Hand the DPP bootstrapping key to the supplicant DER encoded (#20882) 2026-10-26 16:37:25 +00:00
J. Nick Koston 9c54f96520 [libretiny] Send only the UF2 images the device writes over OTA (#21642) 2026-10-26 06:28:54 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] 0cc5b372fb [core] Defer integration imports in ir_rf_base and media_player (#21601)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-26 06:26:00 -10:00
J. Nick Koston f7c8cba8b7 [components] Use pulse_reset for blocking reset pin pulses (#21472) 2026-10-26 06:22:11 -10:00
J. Nick Koston 79a22e9a9a [core] Add pulse_reset for active-low reset pins (#21470) 2026-10-26 06:22:10 -10:00
J. Nick Koston 103023737d [openthread] Resolve IPv6 literal addresses when the DNS64 client is enabled (#19269) 2026-10-26 16:18:12 +00:00
J. Nick Koston 6b6b70a8b6 [dfrobot_sen0395] Collect lines with LineReader (#20979) 2026-10-26 12:16:51 -04:00
100 changed files with 2177 additions and 385 deletions

No files matched your search

+17 -10
View File
@@ -1305,10 +1305,7 @@ def upload_program(
f"{option_string} is only supported with the esphome OTA platform; "
"the web_server OTA path can only update the firmware image."
)
binary = CORE.firmware_bin
if getattr(args, "file", None) is not None:
binary = Path(args.file)
return _upload_via_web_server(config, network_devices, binary)
return _upload_via_web_server(config, network_devices, _app_ota_image(args))
return _upload_via_native_api(config, network_devices, args)
@@ -1364,6 +1361,16 @@ def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
return next(iter(esphome_ota_items(config)), None)
def _app_ota_image(args: ArgsProtocol) -> Path:
"""The file an app update sends: ``--file`` or the build output, as the
target platform's ota_image hook picks it."""
binary = CORE.firmware_bin
if (file := getattr(args, "file", None)) is not None:
binary = Path(file)
ota_image = platform_hooks.get_platform_hook(CORE.target_platform, "ota_image")
return ota_image(binary) if ota_image is not None else binary
def _upload_via_native_api(
config: ConfigType, network_devices: list[str], args: ArgsProtocol
) -> tuple[int, str | None]:
@@ -1413,27 +1420,27 @@ def _upload_via_native_api(
f"flash a build with the option enabled, and then retry {option_string}."
)
binary = CORE.firmware_bin
ota_type = espota2.OTA_TYPE_UPDATE_APP
file = getattr(args, "file", None)
if getattr(args, "partition_table", False):
# Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access.
# The device-side handshake also rejects this with "Device only supports app updates",
# but checking here surfaces the misconfiguration before opening a network connection.
check_partition_access("--partition-table")
binary = CORE.partition_table_bin
binary = Path(file) if file is not None else CORE.partition_table_bin
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
elif getattr(args, "bootloader", False):
check_partition_access("--bootloader")
if (
getattr(args, "file", None) is None
file is None
and (native := native_backend())
and (hint := getattr(native, "missing_image_hint", lambda: None)())
):
raise EsphomeError(hint)
binary = CORE.bootloader_bin
binary = Path(file) if file is not None else CORE.bootloader_bin
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
if getattr(args, "file", None) is not None:
binary = Path(args.file)
else:
binary = _app_ota_image(args)
if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE:
_validate_partition_table_binary(binary)
+1 -3
View File
@@ -297,9 +297,7 @@ void ADE7880::init_device_() {
void ADE7880::reset_device_() {
if (this->reset_pin_ != nullptr) {
ESP_LOGD(TAG, "Reset device using RESET pin");
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
pulse_reset(this->reset_pin_, {.low_ms = 1});
} else {
ESP_LOGD(TAG, "Reset device using SWRST command");
this->write_u16_register16_(CONFIG, CONFIG_SWRST);
@@ -16,13 +16,7 @@ constexpr static const uint8_t AXS_READ_TOUCHPAD[11] = {0xb5, 0xab, 0xa5, 0x5a,
}
void AXS15231Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
delay(10);
}
pulse_reset(this->reset_pin_, {.low_ms = 5, .high_after_ms = 10});
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
this->interrupt_pin_->setup();
+4
View File
@@ -78,3 +78,7 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
@@ -6,12 +6,7 @@ ESPHOME_LOG_TAG(TAG, "cst226.touchscreen");
void CST226Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
this->set_timeout(30, [this] { this->continue_setup_(); });
} else {
this->continue_setup_();
@@ -43,9 +43,7 @@ void CST328Touchscreen::setup() {
}
void CST328Touchscreen::reset_device_() {
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
pulse_reset(this->reset_pin_, {.low_ms = 5});
this->set_timeout(CST328_TRANSITION_TIMEOUT, [this] { this->continue_setup_(); });
}
@@ -52,12 +52,7 @@ void CST816Touchscreen::continue_setup_() {
void CST816Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
this->set_timeout(30, [this] { this->continue_setup_(); });
} else {
this->continue_setup_();
@@ -10,14 +10,7 @@ static constexpr uint32_t REREAD_TIMEOUT_ID = 1;
static constexpr uint32_t REREAD_DELAY_MS = 20;
void CST9220Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 10});
// Wait for the controller to leave its bootloader before talking to it.
this->set_timeout(30, [this] { this->continue_setup_(); });
}
@@ -42,6 +42,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_LINE_READER")
await uart.new_uart_component(config)
@@ -11,10 +11,10 @@
namespace esphome::dfrobot_sen0395 {
ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395");
static constexpr char ASCII_CR = 0x0D;
static constexpr char ASCII_LF = 0x0A;
static constexpr float DET_RANGE_STEP_M = 0.15f;
static constexpr float LATENCY_STEP_S = 0.025f;
// Sent after a reset with no line end
static constexpr char PROMPT[] PROGMEM = "leapMMW:/>";
// What each CommandType cannot do while the sensor runs; empty for commands that do not need it stopped
PROGMEM_STRING_TABLE(BusyActions, "", "configure range config", "configure output latency",
@@ -121,9 +121,9 @@ bool DfrobotSen0395Component::run_command_(const Command &cmd, uint32_t now) {
return false; // Command not done yet
}
if (this->read_message_()) {
if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("is not recognized as a CLI command")) == nullptr) {
if (!this->handle_reply_(cmd))
if (const char *message = this->read_message_()) {
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("is not recognized as a CLI command")) == nullptr) {
if (!this->handle_reply_(cmd, message))
return false; // Command not done yet
this->read_message_(); // Skip the prompt
return true;
@@ -146,12 +146,13 @@ bool DfrobotSen0395Component::run_command_(const Command &cmd, uint32_t now) {
}
void DfrobotSen0395Component::read_state_() {
if (!this->read_message_())
const char *message = this->read_message_();
if (message == nullptr)
return;
if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("$JYBSS,0, , , *")) != nullptr) {
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,0, , , *")) != nullptr) {
this->set_detected_(false);
this->set_active(true);
} else if (ESPHOME_strstr_P(this->read_buffer_, ESPHOME_PSTR("$JYBSS,1, , , *")) != nullptr) {
} else if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,1, , , *")) != nullptr) {
this->set_detected_(true);
this->set_active(true);
}
@@ -206,8 +207,7 @@ void DfrobotSen0395Component::format_command(const Command &cmd, char *buf, size
}
}
bool DfrobotSen0395Component::handle_reply_(const Command &cmd) {
const char *message = this->read_buffer_;
bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *message) {
auto is = [message](ESPHOME_PGM_P text) { return ESPHOME_strcmp_P(message, text) == 0; };
// Commands with their own replies
@@ -230,7 +230,7 @@ bool DfrobotSen0395Component::handle_reply_(const Command &cmd) {
}
return true;
case CommandType::COMMAND_TYPE_RESET_SYSTEM:
if (!is(ESPHOME_PSTR("leapMMW:/>")))
if (!is(PROMPT))
return false;
ESP_LOGI(TAG, "Restarted sensor.");
return true;
@@ -300,34 +300,24 @@ bool DfrobotSen0395Component::handle_reply_(const Command &cmd) {
return true;
}
bool DfrobotSen0395Component::read_message_() {
const char *DfrobotSen0395Component::read_message_() {
while (this->available()) {
uint8_t byte;
this->read_byte(&byte);
if (this->read_pos_ == MMWAVE_READ_BUFFER_LENGTH)
this->read_pos_ = 0;
ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte);
if (byte == ASCII_CR)
continue;
if (byte >= 0x7F)
byte = '?'; // needs to be valid utf8 string for log functions.
this->read_buffer_[this->read_pos_] = byte;
if (this->read_pos_ == 9 && byte == '>')
this->read_buffer_[++this->read_pos_] = ASCII_LF;
if (this->read_buffer_[this->read_pos_] == ASCII_LF) {
this->read_buffer_[this->read_pos_] = 0;
this->read_pos_ = 0;
ESP_LOGV(TAG, "Message: %s", this->read_buffer_);
return true; // Full message in buffer
const char *line = this->line_reader_.feed(byte);
if (line == nullptr && byte == '>' && this->line_reader_.length() == sizeof(PROMPT) - 1) {
line = this->line_reader_.partial();
this->line_reader_.reset();
}
this->read_pos_++;
if (line != nullptr) {
ESP_LOGV(TAG, "Message: %s", line);
return line;
}
this->line_reader_.log_if_overflowed(TAG);
}
return false; // No full message yet
return nullptr;
}
void DfrobotSen0395Component::set_detected_(bool detected) {
@@ -4,6 +4,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/line_reader.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
@@ -14,7 +15,7 @@
namespace esphome::dfrobot_sen0395 {
static constexpr uint8_t MMWAVE_READ_BUFFER_LENGTH = 255;
static constexpr uint8_t MMWAVE_READ_BUFFER_LENGTH = 253; // largest LineReader size with a one byte position
static constexpr uint8_t COMMAND_QUEUE_SIZE = 20;
// Keep in sync with the BusyActions table in dfrobot_sen0395.cpp
@@ -121,8 +122,7 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
bool led_active_{false};
bool uart_presence_active_{false};
bool start_after_boot_{false};
char read_buffer_[MMWAVE_READ_BUFFER_LENGTH];
size_t read_pos_{0};
LineReader<MMWAVE_READ_BUFFER_LENGTH> line_reader_;
bool cmd_sent_{false};
uint8_t retries_left_{2};
StaticRingBuffer<Command, COMMAND_QUEUE_SIZE> cmd_queue_;
@@ -131,12 +131,13 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
int16_t det_range_steps_[8]{};
uint16_t latency_steps_[2]{};
bool read_message_();
/// The next complete line, valid until the next call, or nullptr
const char *read_message_();
void read_state_();
/// Run the command at the front of the queue; true when it is done
bool run_command_(const Command &cmd, uint32_t now);
/// Handle a reply to a sent command; true when the command is done
bool handle_reply_(const Command &cmd);
bool handle_reply_(const Command &cmd, const char *message);
void set_detected_(bool detected);
};
+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_(
@@ -20,9 +20,7 @@ void EKTF2232Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
this->reset_pin_->setup();
this->hard_reset_();
pulse_reset(this->reset_pin_, {.low_ms = 15, .high_after_ms = 15});
if (!this->soft_reset_()) {
ESP_LOGE(TAG, "Failed to soft reset EKT2232!");
this->interrupt_pin_->detach_interrupt();
@@ -96,13 +94,6 @@ bool EKTF2232Touchscreen::get_power_state() {
return (received[1] >> 3) & 1;
}
void EKTF2232Touchscreen::hard_reset_() {
this->reset_pin_->digital_write(false);
delay(15);
this->reset_pin_->digital_write(true);
delay(15);
}
bool EKTF2232Touchscreen::soft_reset_() {
auto err = this->write(SOFT_RESET_CMD, 4);
if (err != i2c::ERROR_OK)
@@ -22,7 +22,6 @@ class EKTF2232Touchscreen final : public Touchscreen, public i2c::I2CDevice {
bool get_power_state();
protected:
void hard_reset_();
bool soft_reset_();
void update_touches() override;
@@ -72,9 +72,7 @@ bool EPaperJD79660::reset() {
// As commented on SLEEP_MS_RESET1: Reset pulse must happen within time window.
// So do not use FSM loop, and avoid other calls/logs during pulse below.
this->reset_pin_->digital_write(false);
delay(SLEEP_MS_RESET1);
this->reset_pin_->digital_write(true);
pulse_reset(this->reset_pin_, {.low_ms = SLEEP_MS_RESET1});
this->reset_duration_ = SLEEP_MS_RESET2;
this->step_ = FSMState::RESET_STEP2_IDLECHECK;
+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,
}
+1 -12
View File
@@ -34,10 +34,7 @@ void FT63X6Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_ANY_EDGE);
}
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->hard_reset_();
}
pulse_reset(this->reset_pin_, {.low_ms = 10});
// Get touch resolution
if (this->x_raw_max_ == this->x_raw_min_) {
@@ -57,14 +54,6 @@ void FT63X6Touchscreen::setup() {
this->write_byte(FT6X36_ADDR_TOUCHRATE_ACTIVE, 0x0E);
}
void FT63X6Touchscreen::hard_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
}
void FT63X6Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG, "FT63X6 Touchscreen:");
LOG_I2C_DEVICE(this);
-1
View File
@@ -27,7 +27,6 @@ class FT63X6Touchscreen final : public Touchscreen, public i2c::I2CDevice {
void set_threshold(uint8_t threshold) { this->threshold_ = threshold; }
protected:
void hard_reset_();
void update_touches() override;
InternalGPIOPin *interrupt_pin_{nullptr};
@@ -83,13 +83,7 @@ void GSL3670Touchscreen::update_touches() {
void GSL3670Touchscreen::clear_reg_() {
ESP_LOGD(TAG, "clear_reg");
// GPIO reset pulse
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
pulse_reset(this->reset_pin_, {.low_ms = 1, .high_after_ms = 5});
this->write_reg8_(0x88, 0x01);
// delay(5);
@@ -106,12 +100,7 @@ void GSL3670Touchscreen::clear_reg_() {
void GSL3670Touchscreen::reset_() {
ESP_LOGD(TAG, "reset");
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
pulse_reset(this->reset_pin_, {.low_ms = 1, .high_after_ms = 5});
this->write_reg8_(0xe4, 0x04);
+1 -6
View File
@@ -19,12 +19,7 @@ void ICNT86Touchscreen::setup() {
}
// Perform reset if necessary
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
pulse_reset(this->reset_pin_, {.low_ms = 10});
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
+4 -3
View File
@@ -1,8 +1,7 @@
"""Shared base for the infrared and radio_frequency entity components."""
import esphome.codegen as cg
from esphome.components import remote_base
from esphome.components.api import DOMAIN as API_DOMAIN
from esphome.const import CONF_API
from esphome.core import CORE
from esphome.core.entity_helpers import queue_entity_register
from esphome.types import ConfigType
@@ -24,8 +23,10 @@ async def attach_transmitter(var: cg.MockObj, config: ConfigType, key: str) -> N
does. Without this call anywhere in the build a request is answered as
soon as the frame is handed over.
"""
from esphome.components import remote_base
await remote_base.register_transmittable(var, config, key)
if API_DOMAIN in CORE.config:
if CONF_API in CORE.config:
cg.add_define("USE_IR_RF_TRANSMIT_COMPLETE")
+26 -6
View File
@@ -138,6 +138,11 @@ static uint8_t calc_checksum(const void *data, size_t size) {
return xor_bytes(static_cast<const uint8_t *>(data), size);
}
// Simple mode needs simple reporting from the module; Normal and Calibrate read energy frames
static uint16_t system_mode_for(uint8_t mode) {
return mode == OP_SIMPLE_MODE ? CMD_SYSTEM_MODE_SIMPLE : CMD_SYSTEM_MODE_ENERGY;
}
static int32_t get_firmware_int(const char *version_string) {
// Convert "v1.5.4" -> 154 by skipping 'v' and '.', accumulating digits
const char *p = (*version_string == 'v') ? version_string + 1 : version_string;
@@ -207,13 +212,10 @@ void LD2420Component::setup() {
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
this->set_operating_mode(OP_SIMPLE_MODE);
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
} else {
this->set_operating_mode(OP_NORMAL_MODE);
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
}
this->set_operating_mode(OP_NORMAL_MODE); // older firmware is held in Simple
this->set_mode_(system_mode_for(this->current_operating_mode));
#ifdef USE_NUMBER
this->init_gate_config_numbers();
#endif
@@ -244,7 +246,9 @@ void LD2420Component::apply_config_action() {
#endif
this->set_system_mode(this->system_mode_);
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
this->set_operating_mode(OP_NORMAL_MODE);
// Applying the calibrated thresholds ends calibration; Normal and Simple stay as they are
if (this->current_operating_mode == OP_CALIBRATE_MODE)
this->set_operating_mode(OP_NORMAL_MODE);
}
void LD2420Component::factory_reset_action() {
@@ -356,6 +360,22 @@ void LD2420Component::set_operating_mode(const char *state) {
}
}
void LD2420Component::change_operating_mode(OpMode mode) {
this->set_operating_mode(mode);
const uint16_t system_mode = system_mode_for(this->current_operating_mode);
if (system_mode == this->get_mode_())
return;
if (this->set_config_mode(true) != LD2420_ERROR_TIMEOUT) {
this->set_system_mode(system_mode);
this->set_config_mode(false);
}
if (this->get_mode_() != system_mode) {
// The module kept its reporting mode; show the mode that matches it
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->set_operating_mode(this->get_mode_() == CMD_SYSTEM_MODE_SIMPLE ? OP_SIMPLE_MODE : OP_NORMAL_MODE);
}
}
void LD2420Component::set_operating_mode(OpMode mode) {
// Older firmware only supports simple mode
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
+1
View File
@@ -119,6 +119,7 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void handle_cmd_error(uint16_t error);
void set_operating_mode(const char *state);
void set_operating_mode(OpMode mode);
void change_operating_mode(OpMode mode);
void auto_calibrate_sensitivity();
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
uint8_t set_config_mode(bool enable);
+1 -2
View File
@@ -30,6 +30,5 @@ async def to_code(config: ConfigType) -> None:
CONF_OPERATING_MODE,
hub.set_operating_mode_select,
options=CONF_SELECTS,
apply="set_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
publish="before",
apply="change_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
)
+21 -1
View File
@@ -26,7 +26,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.helpers import copy_file_if_changed, write_file
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
@@ -656,6 +656,26 @@ async def component_to_code(config):
await cg.register_component(var, config)
# Called by the OTA upload through the platforms' ota_image hook
def device_ota_image(firmware: Path) -> Path:
"""The file to send for an app update: the UF2 without the images the
device skips, written next to the build output, or ``firmware`` itself
when there is nothing to drop."""
from .ota_uf2 import device_ota_uf2
data = firmware.read_bytes()
if (trimmed := device_ota_uf2(data)) is None:
return firmware
image = CORE.relative_pioenvs_path(CORE.name, "firmware.ota.uf2")
write_file(image, trimmed)
_LOGGER.info(
"Sending the images this device writes: %s of %s bytes",
len(trimmed),
len(data),
)
return image
# Called by writer.py
def copy_files() -> None:
script_dir = Path(__file__).parent
@@ -91,6 +91,10 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
'''
BASE_CODE_BOARDS = '''
+98
View File
@@ -0,0 +1,98 @@
"""Trim a LibreTiny UF2 package to the images a device writes over OTA.
A LibreTiny UF2 holds one image per OTA scheme: the device schemes (single,
dual slot 1, dual slot 2) and the flasher schemes used over UART. The device
skips every block of an image whose scheme it does not run (uf2ota), so an
image no device scheme writes is only sent to be thrown away. On single OTA
chips (BK72xx, LN882H) that is the whole flasher image, more than half the
package. The legacy OTA format names the same device partitions, so devices
that read it write the same images.
"""
import struct
UF2_BLOCK_SIZE = 512
UF2_MAGIC_HEAD = struct.pack("<2I", 0x0A324655, 0x9E5D5157)
UF2_MAGIC_TAIL = struct.pack("<I", 0x0AB16F30)
UF2_FLAG_HAS_TAGS = 0x8000
# Header words after the magic: flags, target address, payload length, block
# number, block count
_HEADER_WORDS = struct.Struct("<5I")
_SEQ_OFFSET = 20
_DATA_START = 32
_DATA_END = UF2_BLOCK_SIZE - len(UF2_MAGIC_TAIL)
# One partition index nibble per scheme, high nibble first: device single,
# dual 1 and dual 2 are the first three, the flasher schemes the next three
UF2_TAG_OTA_PART_INFO = 0xC0EE0C
# Tags uf2ota acts on beyond the image they sit in; one in an image that
# would be dropped leaves the package untrimmed
_KEEP_TAGS = frozenset(
{
0x5D57D0, # OTA_FORMAT_1
0x6C8492, # OTA_FORMAT_2
0x6EC68A, # OTA_PART_LIST
0xB948DE, # BINPATCH
0x8288ED, # FAL_PTABLE
}
)
def _tags(block: bytes, length: int) -> dict[int, bytes] | None:
"""The extension tags after a block's payload, or None if malformed."""
tags: dict[int, bytes] = {}
pos = _DATA_START + length
while pos + 4 <= _DATA_END and (size := block[pos]):
if size < 4 or pos + size > _DATA_END:
return None
tags[int.from_bytes(block[pos + 1 : pos + 4], "little")] = block[
pos + 4 : pos + size
]
pos += (size + 3) & ~3
return tags
def device_ota_uf2(data: bytes) -> bytes | None:
"""``data`` without the images no device scheme writes.
None when there is nothing to drop, or when ``data`` is not a well
formed UF2, so the caller sends the file as it is.
"""
if len(data) % UF2_BLOCK_SIZE:
return None
count = len(data) // UF2_BLOCK_SIZE
kept: list[bytes] = []
keep = True
for seq in range(count):
block = data[seq * UF2_BLOCK_SIZE : (seq + 1) * UF2_BLOCK_SIZE]
flags, _, length, block_seq, block_count = _HEADER_WORDS.unpack_from(block, 8)
# A missing or repeated block must stay visible to the device, which
# takes the update size from the block count
if (
block[:8] != UF2_MAGIC_HEAD
or block[_DATA_END:] != UF2_MAGIC_TAIL
or _DATA_START + length > _DATA_END
or (block_seq, block_count) != (seq, count)
):
return None
tags = _tags(block, length) if flags & UF2_FLAG_HAS_TAGS else {}
if tags is None:
return None
if (part_info := tags.get(UF2_TAG_OTA_PART_INFO)) is not None:
# uf2ota rejects a selector without the whole scheme map
if len(part_info) < 3:
return None
# Any of the three device scheme nibbles names a partition
keep = int.from_bytes(part_info[:2], "big") >> 4 != 0
if keep:
kept.append(block)
elif not _KEEP_TAGS.isdisjoint(tags):
# Without its image's OTA_PART_INFO the device would write this
# block to the partition of the image before it
return None
if len(kept) == count:
return None
out = bytearray(b"".join(kept))
for seq in range(len(kept)):
struct.pack_into("<2I", out, seq * UF2_BLOCK_SIZE + _SEQ_OFFSET, seq, len(kept))
return bytes(out)
+4
View File
@@ -77,3 +77,7 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
+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)
+4 -1
View File
@@ -2,7 +2,6 @@ from collections.abc import Callable
from esphome import automation
import esphome.codegen as cg
from esphome.components import audio
from esphome.components.const import CONF_BITRATE
import esphome.config_validation as cv
from esphome.const import (
@@ -116,6 +115,8 @@ def validate_preferred_format(
"""
def validator(config: ConfigType) -> ConfigType:
from esphome.components import audio
# Inherit settings from audio device if not manually set
inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config)
inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config)
@@ -155,6 +156,8 @@ def request_codecs_for_format_configs(
Public API for external components. Do not remove.
"""
from esphome.components import audio
needed_formats: set[str] = set()
need_all = False
+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
+1 -6
View File
@@ -107,12 +107,7 @@ void MipiDsi::setup() {
}
#endif
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
} else {
esp_lcd_panel_io_tx_param(this->io_handle_, SW_RESET_CMD, nullptr, 0);
}
+1 -8
View File
@@ -33,14 +33,7 @@ void MipiRgb::setup_enables_() {
}
delay(10);
}
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
}
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
}
#ifdef USE_SPI
+2 -9
View File
@@ -151,15 +151,8 @@ class MipiSpi : public display::Display,
pin->setup();
pin->digital_write(true);
}
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
// required delay after reset is already in the init sequence, don't duplicate
}
// required delay after reset is already in the init sequence, don't duplicate
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
auto when = millis() + 120;
@@ -226,6 +226,12 @@ def set_sdkconfig_options(config: ConfigType) -> None:
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", not border_router)
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", not border_router)
if not border_router:
# The DNS64 hook breaks IPv6 literal lookups; see openthread_esp.cpp.
# Fixed upstream on ESP-IDF master, remove this once a release includes
# "fix(openthread): bypass DNS64 synthesis for IPv6 literals",
# https://github.com/espressif/esp-idf/commit/87b133ee1a7e91c1ee95176b4733b7f72236e5da
# (merged in https://github.com/espressif/esp-idf/commit/83239509832bf5d427935cd365f4b62a320e8765)
cg.add_build_flag("-Wl,--wrap=lwip_hook_dns_external_resolve")
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5)
if border_router:
@@ -35,6 +35,10 @@
#include "esp_vfs_eventfd.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#ifndef USE_OPENTHREAD_BORDER_ROUTER
#include "lwip/dns.h"
#include "lwip/ip_addr.h"
#endif
ESPHOME_LOG_TAG(TAG, "openthread");
@@ -489,4 +493,26 @@ InstanceLock::~InstanceLock() {
}
} // namespace esphome::openthread
#ifndef USE_OPENTHREAD_BORDER_ROUTER
// ESP-IDF's DNS64 hook runs before lwIP's literal address check and retries every lookup as IPv4,
// so an IPv6 literal never resolves. Let lwIP handle those; linked with --wrap from __init__.py.
// Fixed upstream on ESP-IDF master, remove this once a release includes
// "fix(openthread): bypass DNS64 synthesis for IPv6 literals",
// https://github.com/espressif/esp-idf/commit/87b133ee1a7e91c1ee95176b4733b7f72236e5da
// (merged in https://github.com/espressif/esp-idf/commit/83239509832bf5d427935cd365f4b62a320e8765)
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
int __real_lwip_hook_dns_external_resolve(const char *name, ip_addr_t *addr, dns_found_callback found,
void *callback_arg, u8_t addrtype, err_t *err);
int __wrap_lwip_hook_dns_external_resolve(const char *name, ip_addr_t *addr, dns_found_callback found,
void *callback_arg, u8_t addrtype, err_t *err) {
ip_addr_t literal;
if (ipaddr_aton(name, &literal) && IP_IS_V6_VAL(literal))
return 0;
return __real_lwip_hook_dns_external_resolve(name, addr, found, callback_arg, addrtype, err);
}
} // extern "C"
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_OPENTHREAD_BORDER_ROUTER
#endif
+1 -14
View File
@@ -9,23 +9,10 @@ ESPHOME_LOG_TAG(TAG, "pcd_8544");
void PCD8544::setup_pins_() {
this->spi_setup();
this->init_reset_();
pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10});
this->dc_pin_->setup();
}
void PCD8544::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void PCD8544::initialize() {
this->init_internal_(this->get_buffer_length_());
-2
View File
@@ -57,8 +57,6 @@ class PCD8544 final : public display::DisplayBuffer,
void setup_pins_();
void init_reset_();
size_t get_buffer_length_();
void start_command_();
@@ -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;
}
+4
View File
@@ -77,3 +77,7 @@ async def pin_to_code(config: ConfigType) -> MockObj:
# Called by writer.py; delegates to the shared libretiny implementation.
def copy_files() -> None:
libretiny.copy_files()
# Called by the OTA upload; the shared libretiny implementation.
ota_image = libretiny.device_ota_image
+162 -3
View File
@@ -4,16 +4,28 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, mdns, network, psram, socket, wifi
from esphome.components import (
audio,
esp32,
mdns,
microphone,
network,
psram,
socket,
wifi,
)
from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER
from esphome.components.mdns import DOMAIN as MDNS_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_BUFFER_SIZE,
CONF_CHANNELS,
CONF_ESPHOME,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_MICROPHONE,
CONF_MODEL,
CONF_NAME,
CONF_PROJECT,
@@ -25,6 +37,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +72,10 @@ CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
CONF_ON_STREAMING_STARTED = "on_streaming_started"
CONF_ON_STREAMING_STOPPED = "on_streaming_stopped"
CONF_PRESENT = "present"
# A static pairing code is exactly 8 decimal digits.
STATIC_PAIRING_CODE_DIGITS = 8
@@ -104,6 +121,9 @@ CODECS = {
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
# The source role's limits, matching SourceRoleConfig in sendspin-cpp.
SOURCE_BITS_PER_SAMPLE = (16, 24, 32)
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -117,6 +137,7 @@ AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObj
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
SourceRoleConfig = sendspin_library_ns.struct("SourceRoleConfig")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
@@ -176,6 +197,11 @@ class SendspinConfiguration:
visualizer_support: bool = False
pairing_code_display_support: bool = False
switch_types: set[str] = field(default_factory=set)
# Set by the sendspin.source actions and conditions, which need a source block.
source_referenced: bool = False
source_line_sense: bool = False
# The microphone's sample rate, read during final validation.
source_sample_rate: int | None = None
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -270,6 +296,44 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
return config
_SOURCE_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STREAMING_STARTED, "add_on_source_streaming_started_callback"
),
automation.CallbackAutomation(
CONF_ON_STREAMING_STOPPED, "add_on_source_streaming_stopped_callback"
),
)
def _validate_source_format(config: ConfigType) -> ConfigType:
if config[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE] not in SOURCE_BITS_PER_SAMPLE:
raise cv.Invalid(
f"{CONF_BITS_PER_SAMPLE} must be one of "
f"{', '.join(str(bits) for bits in SOURCE_BITS_PER_SAMPLE)}",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
return config
SOURCE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_MICROPHONE): microphone.microphone_source_schema(
min_bits_per_sample=16,
max_bits_per_sample=32,
min_channels=1,
max_channels=2,
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_ON_STREAMING_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_STREAMING_STOPPED): automation.validate_automation({}),
}
),
_validate_source_format,
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -295,6 +359,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}),
cv.Optional(CONF_SOURCE): SOURCE_SCHEMA,
}
),
cv.only_on_esp32,
@@ -335,9 +400,38 @@ def _final_validate(config: ConfigType) -> ConfigType:
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
_final_validate_source(config)
return config
def _final_validate_source(config: ConfigType) -> None:
data = _get_data()
if (source := config.get(CONF_SOURCE)) is None:
if data.source_referenced:
raise cv.Invalid(
f"The sendspin.source actions and conditions require a '{CONF_SOURCE}' "
"block on the sendspin component"
)
return
mic_source = source[CONF_MICROPHONE]
try:
microphone.final_validate_microphone_source_schema(DOMAIN)(mic_source)
except cv.Invalid as err:
err.prepend([CONF_SOURCE, CONF_MICROPHONE])
raise
# MicrophoneSource cannot resample, so the stream takes the microphone's own rate.
mic_config = fv.get_declaring_config(mic_source[CONF_MICROPHONE])
sample_rate = mic_config.get(audio.CONF_MAX_SAMPLE_RATE)
if sample_rate is None or mic_config.get(audio.CONF_MIN_SAMPLE_RATE) != sample_rate:
raise cv.Invalid(
"The microphone used by the sendspin source must have a fixed sample rate",
path=[CONF_SOURCE, CONF_MICROPHONE],
)
data.source_sample_rate = sample_rate
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -348,6 +442,7 @@ def _request_controller_role(config: ConfigType) -> ConfigType:
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role.
# sendspin.source.is_streaming also uses it.
SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
@@ -381,6 +476,40 @@ automation.register_apply_action(
)
def _request_source(config: ConfigType) -> ConfigType:
"""Mark that a source action or condition is used, so a missing source block is reported."""
_get_data().source_referenced = True
return config
def _request_source_line_sense(config: ConfigType) -> ConfigType:
"""Advertise line sense for the sendspin.source.set_signal action."""
_get_data().source_line_sense = True
return _request_source(config)
automation.register_apply_condition(
"sendspin.source.is_streaming",
cv.All(SENDSPIN_HUB_ACTION_SCHEMA, _request_source),
"is_source_streaming()",
)
automation.register_apply_action(
"sendspin.source.set_signal",
cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
cv.Required(CONF_PRESENT): cv.templatable(cv.boolean),
}
),
_request_source_line_sense,
),
automation.ApplyField(CONF_PRESENT, "set_source_signal", cg.bool_),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -433,9 +562,8 @@ async def to_code(config: ConfigType) -> None:
data = _get_data()
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
# The color role is not yet wired up in ESPHome; disable it in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
@@ -525,6 +653,37 @@ async def to_code(config: ConfigType) -> None:
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if (source := config.get(CONF_SOURCE)) is not None:
cg.add_define("USE_SENDSPIN_SOURCE", True)
mic_source = await microphone.microphone_source_to_code(source[CONF_MICROPHONE])
cg.add(var.set_microphone_source(mic_source))
source_psram_stack = source.get(CONF_TASK_STACK_IN_PSRAM, False)
if source_psram_stack:
psram.request_external_task_stack()
# Designated initializers must follow SourceRoleConfig's declaration order. The fields left out keep
# sendspin-cpp's defaults.
cg.add(
var.set_source_config(
cg.StructInitializer(
SourceRoleConfig,
("sample_rate", data.source_sample_rate),
("channels", len(source[CONF_MICROPHONE][CONF_CHANNELS])),
("bit_depth", source[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]),
("line_sense", data.source_line_sense),
("psram_stack", source_psram_stack),
)
)
)
await automation.build_callback_automations(
var, source, _SOURCE_CALLBACK_AUTOMATIONS
)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
@@ -158,6 +158,23 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
#ifdef USE_SENDSPIN_SOURCE
this->source_role_ = &this->client_->add_source(this->source_config_);
this->source_role_->set_listener(this);
// THREAD CONTEXT: Microphone task, the role's single producer. A capture time of 0 (unknown) makes the library
// stamp the buffer as ending now.
this->microphone_source_->add_data_callback([this](const microphone::AudioCapture &capture) {
// A failed microphone read can publish an empty buffer, which the library rejects with a warning
if (!capture.data.empty()) {
this->source_role_->write_audio(capture.data.data(), capture.data.size(), capture.capture_time_us);
}
});
// A sensor that reports its state at boot can run the set_signal action before this setup()
if (this->source_signal_.has_value()) {
this->set_source_signal(*this->source_signal_);
}
#endif
// Set before setup() by codegen; an unpaired access switch sets it later, from its own setup().
if (this->unpaired_access_.has_value()) {
this->client_->set_unpaired_access_enabled(*this->unpaired_access_);
@@ -223,6 +240,18 @@ void SendspinHub::dump_config() {
preference.display_offset_ms);
}
#endif
#ifdef USE_SENDSPIN_SOURCE
ESP_LOGCONFIG(TAG,
" Source:\n"
" Sample rate: %" PRIu32 " Hz\n"
" Channels: %u\n"
" Bits per sample: %u\n"
" Task stack in PSRAM: %s\n"
" Line sense: %s",
this->source_config_.sample_rate, this->source_config_.channels, this->source_config_.bit_depth,
YESNO(this->source_config_.psram_stack), YESNO(this->source_config_.line_sense));
#endif
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
@@ -564,6 +593,29 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
}
#endif
#ifdef USE_SENDSPIN_SOURCE
// THREAD CONTEXT: Main loop (invoked from the sendspin.source.set_signal action)
void SendspinHub::set_source_signal(bool present) {
if (this->source_role_ == nullptr) {
this->source_signal_ = present; // Applied in setup()
return;
}
this->source_role_->set_signal(present ? sendspin::SourceSignal::PRESENT : sendspin::SourceSignal::ABSENT);
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_started() {
this->microphone_source_->start();
this->source_streaming_started_callbacks_.call();
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_stopped() {
this->microphone_source_->stop();
this->source_streaming_stopped_callbacks_.call();
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP_IDF
@@ -31,6 +31,11 @@
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#endif
#ifdef USE_SENDSPIN_SOURCE
#include "esphome/components/microphone/microphone_source.h"
#include <sendspin/source_role.h>
#endif
#include <array>
#include <cstddef>
@@ -75,6 +80,7 @@ enum SendspinFeature : uint32_t {
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
/// child thereafter, since no fan-out is needed.
/// - The source role streams a microphone. Its data callback runs on the microphone's task.
///
/// The sendspin-cpp library follows this design:
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
@@ -91,6 +97,9 @@ class SendspinHub final : public Component,
#endif
#ifdef USE_SENDSPIN_METADATA
public sendspin::MetadataRoleListener,
#endif
#ifdef USE_SENDSPIN_SOURCE
public sendspin::SourceRoleListener,
#endif
public sendspin::SendspinClientListener,
public sendspin::SendspinNetworkProvider,
@@ -285,6 +294,31 @@ class SendspinHub final : public Component,
sendspin::PlayerRole *get_player_role();
#endif
#ifdef USE_SENDSPIN_SOURCE
void set_source_config(const sendspin::SourceRoleConfig &config) { this->source_config_ = config; }
void set_microphone_source(microphone::MicrophoneSource *microphone_source) {
this->microphone_source_ = microphone_source;
}
/// @brief Reports whether the capture input has a signal; exposed as the sendspin.source.set_signal action.
///
/// Using the action enables line sense in the source config. A call before setup() is applied there. Must be called
/// from the main loop thread.
void set_source_signal(bool present);
/// @brief Returns whether the source is streaming microphone audio to the server.
bool is_source_streaming() const { return this->source_role_ != nullptr && this->source_role_->is_streaming(); }
/// @brief Registers a callback that fires when the server opens the source stream.
template<typename F> void add_on_source_streaming_started_callback(F &&callback) {
this->source_streaming_started_callbacks_.add(std::forward<F>(callback));
}
/// @brief Registers a callback that fires when the source stream closes.
template<typename F> void add_on_source_streaming_stopped_callback(F &&callback) {
this->source_streaming_stopped_callbacks_.add(std::forward<F>(callback));
}
#endif
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
@@ -383,6 +417,22 @@ class SendspinHub final : public Component,
sendspin::PlayerRoleConfig player_config_{};
#endif
#ifdef USE_SENDSPIN_SOURCE
// The server opens and closes the stream; the microphone runs only while it is open.
void on_streaming_started() override;
void on_streaming_stopped() override;
sendspin::SourceRoleConfig source_config_{};
sendspin::SourceRole *source_role_{nullptr};
microphone::MicrophoneSource *microphone_source_{nullptr};
// Signal state set before setup(), applied once the role exists
std::optional<bool> source_signal_;
// Lazy: each is fed by an optional YAML automation.
LazyCallbackManager<void()> source_streaming_started_callbacks_{};
LazyCallbackManager<void()> source_streaming_stopped_callbacks_{};
#endif
// --- Core member variables ---
// Built once in setup(): make_preference() allocates a backend that is never freed.
+1 -12
View File
@@ -122,18 +122,7 @@ void SH1122::fill(Color color) {
const uint8_t fill = (color4 & SH1122_COLORMASK) | ((color4 & SH1122_COLORMASK) << SH1122_COLORSHIFT);
memset(this->buffer_, fill, this->get_buffer_length_());
}
void SH1122::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void SH1122::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
const LogString *SH1122::model_str_() {
switch (this->model_) {
case SH1122_MODEL_256_64:
@@ -355,18 +355,7 @@ void SSD1306::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1306::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void SSD1306::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
const LogString *SSD1306::model_str_() {
return ModelStrings::get_log_str(static_cast<uint8_t>(this->model_), ModelStrings::LAST_INDEX);
}
@@ -184,18 +184,7 @@ void SSD1322::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1322::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void SSD1322::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
const char *SSD1322::model_str_() {
switch (this->model_) {
case SSD1322_MODEL_256_64:
@@ -211,18 +211,7 @@ void SSD1325::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1325::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void SSD1325::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
const char *SSD1325::model_str_() {
switch (this->model_) {
case SSD1325_MODEL_128_32:
@@ -160,18 +160,7 @@ void SSD1327::fill(Color color) {
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
}
void SSD1327::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void SSD1327::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
const char *SSD1327::model_str_() {
switch (this->model_) {
case SSD1327_MODEL_128_128:
@@ -142,17 +142,6 @@ void SSD1331::fill(Color color) {
}
}
}
void SSD1331::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void SSD1331::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
} // namespace esphome::ssd1331_base
@@ -174,18 +174,7 @@ void SSD1351::fill(Color color) {
}
}
}
void SSD1351::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void SSD1351::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
const char *SSD1351::model_str_() {
switch (this->model_) {
case SSD1351_MODEL_128_96:
@@ -9,12 +9,8 @@ ESPHOME_LOG_TAG(TAG, "st7123.touchscreen");
void ST7123Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false); // TP_RESX is active low, assert for at least tRSTW (2ms)
delay(5);
this->reset_pin_->digital_write(true);
// TP_RESX is active low, assert for at least tRSTW (2ms)
pulse_reset(this->reset_pin_, {.high_before_ms = 5, .low_ms = 5});
// The controller needs up to 20ms to initialize after reset before it can be accessed.
this->setup_time_ = millis() + 30;
}
+1 -12
View File
@@ -137,18 +137,7 @@ void ST7567::fill(Color color) {
memset(buffer_, fill, this->get_buffer_length_());
}
void ST7567::init_reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(1);
// Trigger Reset
this->reset_pin_->digital_write(false);
delay(10);
// Wake up
this->reset_pin_->digital_write(true);
}
}
void ST7567::init_reset_() { pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}); }
const char *ST7567::model_str_() { return "ST7567 128x64"; }
+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
@@ -54,10 +54,7 @@ void TT21100Touchscreen::setup() {
}
// Perform reset if necessary
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_();
}
pulse_reset(this->reset_pin_, {.low_ms = 10, .high_after_ms = 10});
// Update display dimensions if they were updated during display setup
if (this->display_ != nullptr) {
@@ -122,15 +119,6 @@ void TT21100Touchscreen::update_touches() {
}
}
void TT21100Touchscreen::reset_() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
delay(10);
}
}
void TT21100Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG, "TT21100 Touchscreen:");
LOG_I2C_DEVICE(this);
@@ -28,8 +28,6 @@ class TT21100Touchscreen final : public Touchscreen, public i2c::I2CDevice {
void register_button_listener(TT21100ButtonListener *listener) { this->button_listeners_.push_back(listener); }
protected:
void reset_();
void update_touches() override;
InternalGPIOPin *interrupt_pin_;
+18 -2
View File
@@ -245,6 +245,22 @@ WiFiConfigureAction = wifi_ns.class_(
)
# The supplicant parses the bootstrapping key as a DER encoded P-256 private key in hex; these wrap the
# 32 byte key the way the ESP-IDF enrollee example does
DPP_KEY_DER_PREFIX = "30310201010420"
DPP_KEY_DER_SUFFIX = "a00a06082a8648ce3d030107"
def _validate_dpp_private_key(value: object) -> str:
"""The key is 32 bytes written as 64 hexadecimal characters."""
value = cv.string_strict(value)
if len(value) != 64 or any(c not in "0123456789abcdefABCDEF" for c in value):
raise cv.Invalid(
"private_key must be 32 bytes written as 64 hexadecimal characters"
)
return value.lower()
def _validate_dpp_device_info(value: object) -> str:
"""The supplicant puts the text on a space separated command line and reads it up to the next space."""
value = cv.string_strict(value)
@@ -322,7 +338,7 @@ EAP_AUTH_SCHEMA = cv.All(
# Wi-Fi Easy Connect (DPP) enrollee; the ESP-IDF supplicant supports the QR code method
DPP_SCHEMA = cv.Schema(
{
cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(cv.string_strict),
cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(_validate_dpp_private_key),
cv.Optional(CONF_DEVICE_INFO): _validate_dpp_device_info,
cv.Optional(CONF_CHANNELS, default=[6]): cv.ensure_list(
cv.int_range(min=1, max=14)
@@ -904,7 +920,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_WIFI_EXT_DPP", True)
if (key := dpp.get(CONF_PRIVATE_KEY)) is not None:
cg.add(var.set_dpp_key(key))
cg.add(var.set_dpp_key(f"{DPP_KEY_DER_PREFIX}{key}{DPP_KEY_DER_SUFFIX}"))
if (info := dpp.get(CONF_DEVICE_INFO)) is not None:
cg.add(var.set_dpp_device_info(info))
cg.add(var.set_dpp_channels(",".join(str(c) for c in dpp[CONF_CHANNELS])))
+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:
+1
View File
@@ -527,6 +527,7 @@
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_SOURCE
#define USE_SENDSPIN_SWITCH
#define USE_SENDSPIN_VISUALIZER
#define USE_SENDSPIN_PORT 8928 // NOLINT
+15
View File
@@ -1,4 +1,5 @@
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome {
@@ -18,4 +19,18 @@ void log_pin(const char *tag, const char *prefix, GPIOPin *pin) {
}
#endif
namespace internal {
void pulse_reset_ms(GPIOPin *pin, uint32_t high_before_ms, uint32_t low_ms, uint32_t high_after_ms) {
if (pin == nullptr)
return;
pin->setup();
const uint32_t waits[] = {high_before_ms, low_ms, high_after_ms};
// Level for each step is high, low, high; a zero high_before_ms skips the first step
for (size_t i = high_before_ms == 0; i < 3; i++) {
pin->digital_write(i != 1);
delay(waits[i]);
}
}
} // namespace internal
} // namespace esphome
+20
View File
@@ -144,4 +144,24 @@ void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin);
void log_pin(const char *tag, const char *prefix, GPIOPin *pin);
#endif
/// Waveform for pulse_reset(): high, low, then high again. Name the fields at the call site, e.g.
/// pulse_reset(this->reset_pin_, {.high_before_ms = 1, .low_ms = 10}).
struct ResetPulse {
uint32_t high_before_ms{0}; ///< Drive high this long first; skipped when 0
uint32_t low_ms{0}; ///< Hold the pin low (in reset) this long
uint32_t high_after_ms{0}; ///< Wait this long after releasing the pin
};
namespace internal {
/// Out-of-line body of pulse_reset(); call that instead.
void pulse_reset_ms(GPIOPin *pin, uint32_t high_before_ms, uint32_t low_ms, uint32_t high_after_ms);
} // namespace internal
/// Set up an active-low reset pin and drive it through \p pulse. Blocks for the whole sequence;
/// does nothing when pin is null. Inline so the fields reach pulse_reset_ms() in registers
/// (RISC-V would otherwise pass the struct by reference).
ESPHOME_ALWAYS_INLINE inline void pulse_reset(GPIOPin *pin, ResetPulse pulse) {
internal::pulse_reset_ms(pin, pulse.high_before_ms, pulse.low_ms, pulse.high_after_ms);
}
} // namespace esphome
+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
)
+9 -4
View File
@@ -26,17 +26,20 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_NRF52,
PLATFORM_RP2,
PLATFORM_RTL87XX,
Platform,
)
_LOGGER = logging.getLogger(__name__)
# Hooks whose loss only degrades diagnostics; skipping one of these is
# logged at debug, while skipping a hook that changes what the CLI does
# (upload method, log transport) warns. A new hook is loud by default.
COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"})
# Hooks whose loss only degrades diagnostics or upload size; skipping one
# of these is logged at debug, while skipping a hook that changes what the
# CLI does (upload method, log transport) warns. A new hook is loud by
# default.
COSMETIC_HOOKS: Final = frozenset({"process_stacktrace", "ota_image"})
# Platforms whose firmware prints the crash handler report share one gate.
_CRASH_HANDLER_GATE = r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT"
@@ -71,6 +74,8 @@ PLATFORM_HOOKS: Final[dict[str, frozenset[str]]] = {
"show_logs": frozenset({PLATFORM_NRF52}),
"upload_program": frozenset({PLATFORM_NRF52}),
"process_stacktrace": frozenset(STACKTRACE_GATES),
# The file an app update sends, when it is not the build output itself
"ota_image": frozenset({PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX}),
}
+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,36 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 16000
sendspin:
source:
microphone: mic
# The condition alone does not report signals, so line sense stays off
interval:
- interval: 10s
then:
- if:
condition:
sendspin.source.is_streaming:
then:
- delay: 1ms
@@ -0,0 +1,29 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 48000
sendspin:
source:
microphone: mic
on_streaming_started:
- sendspin.source.set_signal:
present: true
@@ -0,0 +1,160 @@
"""Validation and code generation tests for the sendspin source role.
These cover the rejection branches of the source block and the generated role
config, which a compile test cannot check: a `test*.yaml` can only assert that a
configuration is accepted.
"""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.automation import ACTION_REGISTRY, CONDITION_REGISTRY
from esphome.components.audio import CONF_MAX_SAMPLE_RATE, CONF_MIN_SAMPLE_RATE
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
from esphome.components.sendspin import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, _get_data
from esphome.config import Config
from esphome.const import CONF_ID, CONF_SOURCE, PlatformFramework
from esphome.core import CORE, ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "sendspin_hub"
MIC_ID = "mic"
def _full_config(min_sample_rate: int = 16000, max_sample_rate: int = 16000) -> Config:
"""A full config carrying one microphone, as the ID pass leaves it."""
mic: ConfigType = {
CONF_ID: ID(MIC_ID, is_declaration=True, type="microphone"),
CONF_MIN_SAMPLE_RATE: min_sample_rate,
CONF_MAX_SAMPLE_RATE: max_sample_rate,
"min_channels": 1,
"max_channels": 2,
"min_bits_per_sample": 16,
"max_bits_per_sample": 32,
}
full = Config()
full["microphone"] = [mic]
full.declare_ids.append((mic[CONF_ID], ["microphone", 0, CONF_ID]))
return full
def _hub_config(**source: Any) -> ConfigType:
"""Validate a hub config with a source block, allowing field overrides."""
source_config: ConfigType = {
"microphone": {"microphone": ID(MIC_ID, is_declaration=False)}
}
source_config.update(source)
return CONFIG_SCHEMA({CONF_ID: HUB_ID, CONF_SOURCE: source_config})
@pytest.fixture
def microphone(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config())
@pytest.mark.usefixtures("microphone")
def test_final_validate_records_sample_rate() -> None:
"""The stream takes the microphone's own rate, since nothing can resample it."""
FINAL_VALIDATE_SCHEMA(_hub_config())
assert _get_data().source_sample_rate == 16000
@pytest.mark.usefixtures("microphone")
def test_unsupported_bits_per_sample_rejected() -> None:
with pytest.raises(cv.Invalid, match="bits_per_sample must be one of"):
_hub_config(microphone={"microphone": MIC_ID, "bits_per_sample": 20})
def test_variable_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(min_sample_rate=16000, max_sample_rate=48000),
)
with pytest.raises(cv.Invalid, match="must have a fixed sample rate"):
FINAL_VALIDATE_SCHEMA(_hub_config())
def test_microphone_source_errors_point_at_the_source(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A channel the microphone does not have is reported at source: microphone:."""
full_config = _full_config()
full_config["microphone"][0]["max_channels"] = 1
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
with pytest.raises(cv.Invalid) as err:
FINAL_VALIDATE_SCHEMA(
_hub_config(microphone={"microphone": MIC_ID, "channels": [0, 1]})
)
assert err.value.path[:2] == [CONF_SOURCE, "microphone"]
@pytest.mark.parametrize(
("registry", "name", "action_config"),
[
(ACTION_REGISTRY, "sendspin.source.set_signal", {"present": True}),
(CONDITION_REGISTRY, "sendspin.source.is_streaming", {}),
],
)
def test_source_automation_without_source_rejected(
set_core_config: SetCoreConfigCallable,
registry: dict[str, Any],
name: str,
action_config: ConfigType,
) -> None:
"""The source action and condition have nothing to act on without a source block."""
set_core_config(PlatformFramework.ESP32_IDF)
registry[name].schema({CONF_ID: ID(HUB_ID, is_declaration=False), **action_config})
with pytest.raises(cv.Invalid, match="require a 'source' block"):
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA({CONF_ID: HUB_ID}))
def _sdkconfig_option(name: str) -> Any:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
def test_line_sense_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The set_signal action turns on line sense."""
main_cpp = generate_main(component_config_path("source_line_sense.yaml"))
assert "set_source_config(" in main_cpp
assert ".sample_rate = 48000" in main_cpp
assert ".line_sense = true" in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is None
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is False
def test_default_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Fields with no option keep sendspin-cpp's defaults, and is_streaming alone leaves line sense off."""
main_cpp = generate_main(component_config_path("source_defaults.yaml"))
assert ".sample_rate = 16000" in main_cpp
assert ".chunk_duration_ms" not in main_cpp
assert ".capture_buffer_ms" not in main_cpp
assert ".line_sense = false" in main_cpp
assert ".buffer_location" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is False
def test_no_source_disables_the_role(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "set_source_config(" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is False
@@ -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
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
dpp:
private_key: 00112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeeff
@@ -0,0 +1,42 @@
"""The DPP bootstrapping key is given as 32 raw bytes in hex and handed to the supplicant DER encoded."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components import wifi
import esphome.config_validation as cv
RAW_KEY = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
@pytest.mark.parametrize(
"value",
[
RAW_KEY[:-2],
RAW_KEY + "00",
"zz" + RAW_KEY[2:],
wifi.DPP_KEY_DER_PREFIX + RAW_KEY + wifi.DPP_KEY_DER_SUFFIX,
],
)
def test_key_must_be_64_hex_digits(value: str) -> None:
with pytest.raises(cv.Invalid, match="64 hexadecimal characters"):
wifi._validate_dpp_private_key(value)
def test_key_is_lower_cased() -> None:
assert wifi._validate_dpp_private_key(RAW_KEY.upper()) == RAW_KEY
def test_generated_key_is_der_encoded(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The ESP-IDF supplicant expects the P-256 DER header around the raw key."""
main_cpp = generate_main(component_config_path("dpp_key.yaml"))
assert (
f'set_dpp_key("{wifi.DPP_KEY_DER_PREFIX}{RAW_KEY}{wifi.DPP_KEY_DER_SUFFIX}")'
in main_cpp
)
@@ -0,0 +1,51 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
namespace esphome::core::testing {
class RecordingPin : public GPIOPin {
public:
void setup() override { this->events += 'S'; }
void pin_mode(gpio::Flags /*flags*/) override {}
gpio::Flags get_flags() const override { return gpio::FLAG_OUTPUT; }
bool digital_read() override { return false; }
void digital_write(bool value) override {
this->events += value ? 'H' : 'L';
this->write_ms.push_back(millis());
}
std::string events;
std::vector<uint32_t> write_ms;
};
TEST(PulseReset, NullPinIsIgnored) { pulse_reset(nullptr, {.low_ms = 1}); }
TEST(PulseReset, LowThenHigh) {
RecordingPin pin;
pulse_reset(&pin, {.low_ms = 1});
EXPECT_EQ(pin.events, "SLH");
}
TEST(PulseReset, PreHighDrivesHighFirst) {
RecordingPin pin;
pulse_reset(&pin, {.high_before_ms = 1, .low_ms = 1, .high_after_ms = 1});
EXPECT_EQ(pin.events, "SHLH");
}
// Distinct waits, so dropping or swapping one shows up; only lower bounds, a busy host can run long
TEST(PulseReset, HoldsEachLevelForItsTime) {
RecordingPin pin;
pulse_reset(&pin, {.high_before_ms = 10, .low_ms = 20, .high_after_ms = 30});
const uint32_t end = millis();
ASSERT_EQ(pin.events, "SHLH");
EXPECT_GE(pin.write_ms[1] - pin.write_ms[0], 10u);
EXPECT_GE(pin.write_ms[2] - pin.write_ms[1], 20u);
EXPECT_GE(end - pin.write_ms[2], 30u);
}
} // namespace esphome::core::testing
@@ -0,0 +1,14 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# One empty config entry so to_code runs and sets the define that keeps
# core/line_reader.cpp in the test build.
manifest.multi_conf = False
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_LINE_READER")
manifest.to_code = to_code
@@ -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
@@ -0,0 +1,40 @@
packages:
sendspin: !include common.yaml
microphone:
- platform: i2s_audio
id: sendspin_mic_id
i2s_audio_id: i2s_audio_bus
i2s_din_pin: ${i2s_din_pin}
adc_type: external
channel: stereo
sample_rate: 48000
bits_per_sample: 16bit
sendspin:
source:
microphone:
microphone: sendspin_mic_id
channels: [0, 1]
bits_per_sample: 16
gain_factor: 2
task_stack_in_psram: true
on_streaming_started:
then:
- logger.log: "Source streaming started"
- sendspin.source.set_signal:
present: true
on_streaming_stopped:
then:
- logger.log: "Source streaming stopped"
- sendspin.source.set_signal:
present: !lambda return false;
interval:
- interval: 10s
then:
- if:
condition:
sendspin.source.is_streaming:
then:
- logger.log: "Source is streaming"
@@ -0,0 +1,6 @@
substitutions:
i2s_din_pin: GPIO39
packages:
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
sendspin: !include common-source.yaml
@@ -0,0 +1,21 @@
# The defaults, with a 24-bit mono source at 44.1 kHz
substitutions:
i2s_din_pin: GPIO39
packages:
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
sendspin: !include common.yaml
microphone:
- platform: i2s_audio
id: sendspin_mic_id
i2s_audio_id: i2s_audio_bus
i2s_din_pin: ${i2s_din_pin}
adc_type: external
sample_rate: 44100
sendspin:
source:
microphone:
microphone: sendspin_mic_id
bits_per_sample: 24
+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
@@ -2,6 +2,7 @@ wifi:
dpp:
channels: [1, 6, 11]
device_info: ESPHome
private_key: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
timeout: 30s
on_dpp_uri:
- logger.log:
@@ -0,0 +1,70 @@
esphome:
name: uart-mock-dfrobot-sen0395
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 115200
# A small model of the module's command line: echo, answer and the prompt (no line end)
on_tx:
- then:
- lambda: |-
std::string cmd(data.begin(), data.end());
std::string reply = cmd + "\r\n";
if (cmd == "resetSystem") {
// The restart only answers with the prompt
} else {
reply += "Done\r\n";
}
reply += "leapMMW:/>";
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>(reply.begin(), reply.end()));
dfrobot_sen0395:
id: sen0395
uart_id: mock_uart
binary_sensor:
- platform: dfrobot_sen0395
name: Detected
switch:
- platform: dfrobot_sen0395
type: sensor_active
name: Sensor Active
button:
- platform: template
name: Inject Present
on_press:
- lambda: |-
// '$' is kept apart so it is not read as a substitution
std::string frame = std::string(1, '$') + "JYBSS,1, , , *\r\n";
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>(frame.begin(), frame.end()));
- platform: template
name: Inject Overlong Then Absent
on_press:
- lambda: |-
// An overlong line is dropped up to its line end; the next frame still parses
std::string data(300, 'x');
data += "\r\n" + std::string(1, '$') + "JYBSS,0, , , *\r\n";
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>(data.begin(), data.end()));
- platform: template
name: Reset Sensor
on_press:
- dfrobot_sen0395.reset:
id: sen0395
@@ -0,0 +1,96 @@
esphome:
name: uart-mock-ld2420-select-mode
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: false
responses:
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Generic ACK for every other command
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Sent after the select switched the module to Simple: "ON Range 0035\r\n" is only
# parsed while the component expects simple frames.
- delay: 100ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
0x30, 0x30, 0x33, 0x35,
0x0D, 0x0A,
]
button:
- platform: template
name: "Start Scenario"
on_press:
- lambda: 'id(mock_uart).start_scenario();'
# A changed timeout gets apply_config_action() past its no-change check
- platform: template
name: "Apply Changed Config"
on_press:
- lambda: |-
id(ld2420_dev).new_config.timeout++;
id(ld2420_dev).apply_config_action();
- platform: template
name: "Inject Simple Frame"
on_press:
- lambda: |-
// "ON Range 0040\r\n"
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, 0x30, 0x30, 0x34, 0x30, 0x0D, 0x0A});
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
select:
- platform: ld2420
ld2420_id: ld2420_dev
operating_mode:
name: "Operating Mode"
@@ -0,0 +1,73 @@
esphome:
name: uart-mock-ld2420-mode-nack
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 115200
responses:
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# The simple system mode write is refused (error word 1)
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00,
0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x01, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Generic ACK for every other command
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
select:
- platform: ld2420
ld2420_id: ld2420_dev
operating_mode:
name: "Operating Mode"
@@ -0,0 +1,93 @@
"""Integration test for the DFRobot SEN0395 component against a model of the module's command line.
Checks presence frames, recovery after an overlong line, a command answered by "Done" and the
reset command, which the module answers only with its "leapMMW:/>" prompt (no line end).
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from aioesphomeapi import (
BinarySensorInfo,
ButtonInfo,
EntityInfo,
EntityState,
SwitchInfo,
)
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_uart_mock_dfrobot_sen0395(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Frames parse, an overlong line is dropped and the prompt completes the reset."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
dropped = loop.create_future()
restarted = loop.create_future()
def line_callback(line: str) -> None:
if "Line over 252 chars dropped" in line and not dropped.done():
dropped.set_result(True)
if "Restarted sensor." in line and not restarted.done():
restarted.set_result(True)
states: dict[int, EntityState] = {}
async def wait_for(condition: Callable[[], bool], what: str) -> None:
try:
async with asyncio.timeout(10.0):
while not condition():
await asyncio.sleep(0.05)
except TimeoutError:
pytest.fail(f"Timeout waiting for {what}; states: {states}")
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
detected = require_entity(entities, "detected", BinarySensorInfo)
active = require_entity(entities, "sensor_active", SwitchInfo)
present = require_entity(entities, "inject_present", ButtonInfo)
overlong = require_entity(entities, "inject_overlong_then_absent", ButtonInfo)
reset = require_entity(entities, "reset_sensor", ButtonInfo)
client.subscribe_states(lambda state: states.__setitem__(state.key, state))
def state_of(entity: EntityInfo) -> object | None:
state = states.get(entity.key)
return None if state is None or state.missing_state else state.state
client.button_command(present.key)
await wait_for(lambda: state_of(detected) is True, "presence")
client.button_command(overlong.key)
await wait_for(
lambda: state_of(detected) is False, "absence after overlong line"
)
await asyncio.wait_for(dropped, timeout=5.0)
client.switch_command(active.key, False)
await wait_for(lambda: state_of(active) is False, "sensor stopped")
client.button_command(reset.key)
try:
await asyncio.wait_for(restarted, timeout=10.0)
except TimeoutError:
pytest.fail("The reset was not completed by the prompt")
@@ -0,0 +1,128 @@
"""Integration test for the LD2420 operating mode select with a mock UART.
The module runs firmware v2.0.0, so it starts in energy reporting. Choosing Simple
must write the simple system mode to the module and parse simple frames afterwards;
choosing Normal again must write the energy system mode back.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, EntityState, SelectInfo, SelectState
import pytest
from .log_utils import LineWaiter
from .state_utils import (
SensorStateCollector,
require_entity,
subscribe_after_initial_states,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# CMD_WRITE_SYS_PARAM (0x0012), parameter 0x0000 (system mode), then the mode and 0x0000
WRITE_SIMPLE = "FD:FC:FB:FA:08:00:12:00:00:00:64:00:00:00:04:03:02:01"
WRITE_ENERGY = "FD:FC:FB:FA:08:00:12:00:00:00:04:00:00:00:04:03:02:01"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_select_mode(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The select switches the module between energy and simple reporting."""
waiter = LineWaiter()
collector = SensorStateCollector(sensor_names=["moving_distance"])
simple_frame_parsed = collector.add_waiter(
lambda: pytest.approx(35.0) in collector.sensor_states["moving_distance"]
)
second_frame_parsed = collector.add_waiter(
lambda: pytest.approx(40.0) in collector.sensor_states["moving_distance"]
)
modes: list[str] = []
back_to_normal = asyncio.Event()
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, SelectState):
modes.append(state.state)
if modes[-2:] == ["Calibrate", "Normal"]:
back_to_normal.set()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
await subscribe_after_initial_states(client, entities, on_state=on_state)
mode = require_entity(entities, "operating_mode", SelectInfo)
apply_changed = require_entity(entities, "apply_changed_config", ButtonInfo)
# Setup wrote energy mode for this firmware
await waiter.wait_for("TX", WRITE_ENERGY)
writes_before = waiter.count(WRITE_ENERGY)
client.select_command(mode.key, "Simple")
await waiter.wait_for("TX", WRITE_SIMPLE)
client.button_command(
require_entity(entities, "start_scenario", ButtonInfo).key
)
await wait_or_fail(simple_frame_parsed, 3.0, "Simple frame was not parsed")
# Applying a changed config in Simple keeps Simple
client.button_command(apply_changed.key)
await waiter.wait_for("Reconfiguring")
inject = require_entity(entities, "inject_simple_frame", ButtonInfo)
async def inject_until_parsed() -> None:
# Simple reports are rate limited, so keep sending until one is published
while not second_frame_parsed.done():
client.button_command(inject.key)
await asyncio.sleep(0.2)
await wait_or_fail(inject_until_parsed(), 5.0, "Simple stopped after apply")
assert modes[-1] == "Simple"
client.select_command(mode.key, "Normal")
await waiter.wait_count(
WRITE_ENERGY, writes_before + 1, 3.0, "Normal did not write energy mode"
)
assert waiter.count(WRITE_ENERGY) == writes_before + 1
# Applying a changed config ends calibration
client.select_command(mode.key, "Calibrate")
client.button_command(apply_changed.key)
await wait_or_fail(back_to_normal, 5.0, "Apply did not end calibration")
@pytest.mark.asyncio
async def test_uart_mock_ld2420_select_mode_nack(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A refused Simple write puts the select back on the mode the module reports."""
waiter = LineWaiter()
states: list[str] = []
back_to_normal = asyncio.Event()
def on_state(state: EntityState) -> None:
if isinstance(state, SelectState):
states.append(state.state)
if states[-2:] == ["Simple", "Normal"]:
back_to_normal.set()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
await subscribe_after_initial_states(client, entities, on_state=on_state)
mode = require_entity(entities, "operating_mode", SelectInfo)
client.select_command(mode.key, "Simple")
# Three tries of about a second each before the write is given up
await wait_or_fail(back_to_normal, 6.0, "Select did not go back to Normal")
assert waiter.count(WRITE_SIMPLE) == 3
@@ -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}]}
)
@@ -0,0 +1,125 @@
"""Tests for trimming a LibreTiny UF2 to the images a device writes over OTA."""
from pathlib import Path
import struct
import pytest
from esphome.components.libretiny import device_ota_image
from esphome.components.libretiny.ota_uf2 import UF2_TAG_OTA_PART_INFO, device_ota_uf2
from esphome.core import CORE
# Written from the UF2 and LibreTiny formats, not from the module under test
MAGIC_1, MAGIC_2, MAGIC_3 = 0x0A324655, 0x9E5D5157, 0x0AB16F30
FLAG_FAMILY, FLAG_TAGS = 0x2000, 0x8000
FAMILY = 0x7B3EF230
TAG_OTA_FORMAT_2 = 0x6C8492
TAG_BINPATCH = 0xB948DE
# OTA_PART_INFO index nibbles: device single, dual 1, dual 2, flasher single, dual 1, dual 2
DEVICE_SINGLE = b"\x10\x00\x00"
FLASHER_SINGLE = b"\x00\x01\x00"
DEVICE_DUAL_1 = b"\x01\x00\x00"
DEVICE_DUAL_2 = b"\x00\x10\x00"
def _tag(tag: int, value: bytes) -> bytes:
size = 4 + len(value)
return bytes([size]) + tag.to_bytes(3, "little") + value + bytes(-size % 4)
def _block(payload: bytes = b"", tags: dict[int, bytes] | None = None) -> bytes:
"""A block numbered 0 of 0; _package numbers it."""
flags = FLAG_FAMILY | (FLAG_TAGS if tags else 0)
header = struct.pack("<8I", MAGIC_1, MAGIC_2, flags, 0, len(payload), 0, 0, FAMILY)
body = payload + b"".join(_tag(t, v) for t, v in (tags or {}).items())
return header + body + bytes(512 - 32 - 4 - len(body)) + struct.pack("<I", MAGIC_3)
def _image(part_info: bytes, blocks: int, fill: int) -> list[bytes]:
"""An image: its first block names the partitions, the rest carry data."""
data = bytes([fill]) * 256
first = _block(data, {UF2_TAG_OTA_PART_INFO: part_info + b"part\x00"})
return [first] + [_block(data) for _ in range(blocks - 1)]
def _package(*blocks: bytes) -> bytes:
"""The blocks numbered in order, as a UF2 file."""
return b"".join(
block[:20] + struct.pack("<2I", seq, len(blocks)) + block[28:]
for seq, block in enumerate(blocks)
)
HEADER = _block(tags={TAG_OTA_FORMAT_2: b"\x02"})
FLASHER = _image(FLASHER_SINGLE, 5, 0xAA)
DEVICE = _image(DEVICE_SINGLE, 3, 0xBB)
SLOT_1 = _image(DEVICE_DUAL_1, 2, 0x11)
SLOT_2 = _image(DEVICE_DUAL_2, 2, 0x22)
PATCHED = _block(bytes([0xAA]) * 256, {TAG_BINPATCH: b"\x01\x02\x03\x04"})
@pytest.mark.parametrize(
("blocks", "expected"),
[
pytest.param([HEADER, *FLASHER, *DEVICE], [HEADER, *DEVICE], id="single_ota"),
pytest.param(
[HEADER, *SLOT_1, *FLASHER, *SLOT_2],
[HEADER, *SLOT_1, *SLOT_2],
id="dual_ota_keeps_both_slots",
),
pytest.param(
[HEADER, *FLASHER, PATCHED, *DEVICE],
None,
id="dropped_image_carries_a_tag_the_device_acts_on",
),
pytest.param([HEADER, *DEVICE], None, id="nothing_to_drop"),
],
)
def test_device_ota_uf2(blocks: list[bytes], expected: list[bytes] | None) -> None:
trimmed = device_ota_uf2(_package(*blocks))
assert trimmed == (_package(*expected) if expected is not None else None)
def test_data_that_is_not_a_well_formed_uf2_is_left_alone() -> None:
package = _package(HEADER, *FLASHER, *DEVICE)
bad_magic = package[:512] + b"\x00" + package[513:]
# A tag that runs past the data area of the first flasher block
at = 512 + 32 + 256
bad_tag = package[:at] + bytes([250]) + package[at + 1 :]
# A whole block missing, or repeated, keeps the original numbering
missing = package[:1024] + package[1536:]
repeated = package[:1536] + package[1024:]
# OTA_PART_INFO too short to hold the scheme map, which uf2ota rejects
short_selectors = [
_package(HEADER, _block(bytes(256), {UF2_TAG_OTA_PART_INFO: bytes(n)}), *DEVICE)
for n in range(3)
]
for data in (
b"",
bytes(range(256)) * 4,
package[:-1],
bad_magic,
bad_tag,
missing,
repeated,
*short_selectors,
):
assert device_ota_uf2(data) is None
def test_device_ota_image(setup_core: Path) -> None:
tmp_path = setup_core
CORE.build_path = tmp_path / "build"
CORE.name = "test"
firmware = tmp_path / "firmware.uf2"
firmware.write_bytes(_package(HEADER, *FLASHER, *DEVICE))
image = device_ota_image(firmware)
assert image == CORE.relative_pioenvs_path("test", "firmware.ota.uf2")
assert image.read_bytes() == _package(HEADER, *DEVICE)
# Nothing to drop: the firmware itself is sent
firmware.write_bytes(_package(HEADER, *DEVICE))
assert device_ota_image(firmware) == firmware
+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)
+4 -25
View File
@@ -222,15 +222,9 @@ def test_api_client_does_not_import_heavy_modules() -> None:
# Packages a domain needs for its own schema; cheap and never an integration
_ENTITY_COMPONENT_DEPENDENCIES = {
"infrared": ("esphome.components.ir_rf_base",),
"light": ("esphome.components.power_supply", "esphome.components.const.css_colors"),
}
# Entity domains that still import integrations at top level
_ENTITY_COMPONENT_KNOWN_LEAKS = {
"infrared": "imports remote_base and api at top level",
"media_player": "audio pulls in the esp32 chain",
"radio_frequency": "imports remote_base and api at top level",
"radio_frequency": ("esphome.components.ir_rf_base",),
}
@@ -248,26 +242,11 @@ def _entity_components() -> list[str]:
def test_entity_component_tables_match_discovery() -> None:
"""Catch stale table entries and a discovery that silently finds nothing."""
found = set(_entity_components())
stale = (
_ENTITY_COMPONENT_KNOWN_LEAKS.keys() | _ENTITY_COMPONENT_DEPENDENCIES.keys()
) - found
stale = _ENTITY_COMPONENT_DEPENDENCIES.keys() - found
assert not stale, f"not discovered as entity components: {sorted(stale)}"
@pytest.mark.parametrize(
"component",
[
pytest.param(
c,
marks=pytest.mark.xfail(
reason=_ENTITY_COMPONENT_KNOWN_LEAKS[c], strict=True
),
)
if c in _ENTITY_COMPONENT_KNOWN_LEAKS
else c
for c in _entity_components()
],
)
@pytest.mark.parametrize("component", _entity_components())
def test_entity_component_does_not_import_integrations(component: str) -> None:
"""An entity component must not drag in its optional integrations.
+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):
+112
View File
@@ -2135,6 +2135,118 @@ def test_upload_program_ota_success(
)
@pytest.mark.parametrize(
("config", "uploader", "image_arg"),
[
pytest.param(
{CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 8892}]},
"mock_run_ota",
3,
id="native",
),
pytest.param(
{
CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}],
CONF_WEB_SERVER: {CONF_PORT: 80},
},
"mock_run_web_server_ota",
4,
id="web_server",
),
],
)
def test_upload_program_ota_sends_the_platform_ota_image(
config: ConfigType,
uploader: str,
image_arg: int,
mock_get_port_type: Mock,
tmp_path: Path,
request: pytest.FixtureRequest,
) -> None:
"""An app update sends the file the platform's ota_image hook picks."""
setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_upload = request.getfixturevalue(uploader)
mock_upload.return_value = (0, "192.168.1.100")
image = tmp_path / "firmware.ota.uf2"
with patch(
"esphome.components.bk72xx.ota_image", return_value=image
) as mock_ota_image:
exit_code, _ = upload_program(config, MockArgs(), ["192.168.1.100"])
assert exit_code == 0
mock_ota_image.assert_called_once_with(CORE.firmware_bin)
assert mock_upload.call_args.args[image_arg] == image
def test_upload_program_ota_file_goes_through_the_ota_image_hook(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""--file is the firmware the platform's ota_image hook starts from."""
setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_ota.return_value = (0, "192.168.1.100")
other = tmp_path / "other.uf2"
image = tmp_path / "firmware.ota.uf2"
config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 8892}]}
with patch(
"esphome.components.bk72xx.ota_image", return_value=image
) as mock_ota_image:
upload_program(config, MockArgs(file=str(other)), ["192.168.1.100"])
mock_ota_image.assert_called_once_with(other)
assert mock_run_ota.call_args.args[3] == image
def test_upload_program_ota_partition_table_skips_the_ota_image_hook(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""Only app updates go through the ota_image hook."""
setup_core(platform=PLATFORM_BK72XX, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_ota.return_value = (0, "192.168.1.100")
partition_file = tmp_path / "partitions.bin"
partition_file.write_bytes(_make_partition_table_bytes())
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 8892,
"allow_partition_access": True,
}
]
}
args = MockArgs(file=str(partition_file), partition_table=True)
with patch("esphome.components.bk72xx.ota_image") as mock_ota_image:
upload_program(config, args, ["192.168.1.100"])
mock_ota_image.assert_not_called()
assert mock_run_ota.call_args.args[3] == partition_file
def test_upload_program_ota_without_a_hook_sends_the_firmware(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""A platform without an ota_image hook sends its build output."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_get_port_type.return_value = "NETWORK"
mock_run_ota.return_value = (0, "192.168.1.100")
config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]}
upload_program(config, MockArgs(), ["192.168.1.100"])
assert mock_run_ota.call_args.args[3].name == "firmware.bin"
@pytest.mark.parametrize(
"old_key",
[None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="],