Merge branch 'dev' into esp32-operator-new

This commit is contained in:
J. Nick Koston
2026-10-14 11:47:14 -10:00
committed by GitHub
46 changed files with 695 additions and 157 deletions
+5 -2
View File
@@ -3043,12 +3043,15 @@ enum SerialProxyIdentityFlag {
// IDs below are empty
}
// The descriptor fields of a USB device as seen by the host stack
// The descriptor fields of a USB device as seen by the host stack, and the port's index
// among those a vendor driver exposes on the interface
message UsbDeviceDescriptor {
uint32 vendor_id = 1;
uint32 product_id = 2;
uint32 bcd_device = 3;
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
uint32 interface_number = 4; // bInterfaceNumber the port is bound through
uint32 interface_class = 5; // bInterfaceClass of that interface
uint32 port_number = 6;
}
// The identity of the device behind a port. Identifies one physical device among others of
+12
View File
@@ -1,4 +1,7 @@
#include "api_connection.h"
#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER)
#include "esphome/components/logger/logger.h"
#endif
#ifdef USE_API
#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines
#ifdef USE_API_NOISE
@@ -451,6 +454,15 @@ bool APIConnection::send_disconnect_response_() {
DisconnectResponse resp;
return this->send_message(resp);
}
void APIConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) {
this->flags_.log_subscription = msg.level;
if (msg.dump_config)
App.schedule_dump_config();
#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER)
logger::log_crash_to_client();
#endif
}
void APIConnection::on_disconnect_response() {
// Don't close socket here, let APIServer::loop() do it
// so getpeername() still works for the disconnect trigger
+1 -24
View File
@@ -15,15 +15,6 @@
#include "subscribe_state.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
#include "esphome/components/esp32/crash_handler.h"
#endif
#ifdef USE_RP2_CRASH_HANDLER
#include "esphome/components/rp2/crash_handler.h"
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
#include "esphome/components/esp8266/crash_handler.h"
#endif
#include "esphome/core/entity_base.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
@@ -298,21 +289,7 @@ class APIConnection final : public APIServerConnectionBase {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
}
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) {
this->flags_.log_subscription = msg.level;
if (msg.dump_config)
App.schedule_dump_config();
#ifdef USE_ESP32_CRASH_HANDLER
esp32::crash_handler_log();
esp32::crash_handler_clear();
#endif
#ifdef USE_RP2_CRASH_HANDLER
rp2::crash_handler_log();
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
esp8266::crash_handler_log();
#endif
}
void on_subscribe_logs_request(const SubscribeLogsRequest &msg);
#ifdef USE_API_HOMEASSISTANT_SERVICES
void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; }
#endif
+4
View File
@@ -4175,6 +4175,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number);
return pos;
}
uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
@@ -4184,6 +4186,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.product_id);
size += ProtoSize::calc_uint32(1, msg.bcd_device);
size += ProtoSize::calc_uint32(1, msg.interface_number);
size += ProtoSize::calc_uint32(1, msg.interface_class);
size += ProtoSize::calc_uint32(1, msg.port_number);
return size;
}
uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
+2
View File
@@ -4083,6 +4083,8 @@ class UsbDeviceDescriptor final : public ProtoMessage {
uint32_t product_id{0};
uint32_t bcd_device{0};
uint32_t interface_number{0};
uint32_t interface_class{0};
uint32_t port_number{0};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+2
View File
@@ -2942,6 +2942,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class);
dump_field(out, ESPHOME_PSTR("port_number"), this->port_number);
return out.c_str();
}
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
+1
View File
@@ -2920,6 +2920,7 @@ async def _finalize_arduino_aware_flags():
if not arduino_linked:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
cg.add_define("USE_CRASH_HANDLER")
elif not CORE.using_arduino:
cg.add_build_flag("-DUSE_ARDUINO")
+3 -2
View File
@@ -161,7 +161,7 @@ static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-c
namespace esphome::esp32 {
ESPHOME_LOG_TAG(TAG, "esp32.crash");
ESPHOME_LOG_TAG(TAG, CRASH_LOG_TAG);
// RAM copy of the build timestamp. The generated constant lives in flash,
// which the panic handler must not read (cache may be disabled during
@@ -175,7 +175,8 @@ static uint32_t s_current_build_time = static_cast<uint32_t>(ESPHOME_BUILD_TIME)
// Validate the NOINIT record. Runs on every has_data() call; re-running is
// harmless and the magic is left alone so the record survives an OTA
// rollback reboot, crash_handler_clear() drops it once an API client has it.
// rollback reboot, crash_handler_clear() drops it once a log client has it
// (logger::log_crash_to_client(), for the API and MQTT).
static void read_crash_data() {
if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) {
s_crash_data_valid = true;
+3
View File
@@ -4,6 +4,9 @@
namespace esphome::esp32 {
/// The tag the crash record is logged under, so a client can check the level in force for it
inline constexpr const char *CRASH_LOG_TAG = "esp32.crash";
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
+1
View File
@@ -420,6 +420,7 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("ESPHOME_VARIANT", "ESP8266")
cg.add_define(ThreadModel.SINGLE)
cg.add_define("USE_ESP8266_CRASH_HANDLER")
cg.add_define("USE_CRASH_HANDLER")
enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT)
if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config):
@@ -1,6 +1,7 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import audio, esp32, speaker
from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
@@ -14,9 +15,11 @@ from esphome.const import (
CONF_SAMPLE_RATE,
CONF_TIMEOUT,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import (
CONF_I2S_AUDIO_ID,
CONF_I2S_DOUT_PIN,
CONF_I2S_MODE,
CONF_LEFT,
@@ -44,6 +47,7 @@ I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase)
CONF_DAC_TYPE = "dac_type"
CONF_I2S_COMM_FMT = "i2s_comm_fmt"
CONF_KEEP_ALIVE = "keep_alive"
CONF_SPDIF_MODE = "spdif_mode"
I2SAudioSpeakerBase = i2s_audio_ns.class_(
@@ -173,6 +177,7 @@ BASE_SCHEMA = (
cv.positive_time_period_milliseconds,
cv.one_of(CONF_NEVER, lower=True),
),
cv.Optional(CONF_KEEP_ALIVE, default=False): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -235,6 +240,23 @@ def _final_validate(config: ConfigType) -> None:
raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'")
if config[CONF_MCLK_MULTIPLE] != 256:
raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'")
if config[CONF_KEEP_ALIVE]:
raise cv.Invalid("SPDIF mode does not support 'keep_alive'")
if config[CONF_KEEP_ALIVE]:
# A kept channel holds the port between sessions, so nothing else may share the bus
bus = config[CONF_I2S_AUDIO_ID]
full_config = fv.full_config.get()
on_bus = sum(
1
for domain in (speaker.DOMAIN, MICROPHONE_DOMAIN)
for entry in full_config.get(domain, [])
if entry.get(CONF_I2S_AUDIO_ID) == bus
)
if on_bus > 1:
raise cv.Invalid(
"'keep_alive' needs its own I2S bus; another speaker or microphone shares this one"
)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -262,6 +284,8 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_TIMEOUT] != CONF_NEVER:
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION]))
if config[CONF_KEEP_ALIVE]:
cg.add(var.set_keep_alive(True))
# The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE,
@@ -56,8 +56,9 @@ void I2SAudioSpeakerBase::dump_config() {
ESP_LOGCONFIG(TAG,
"Speaker:\n"
" Pin: %d\n"
" Buffer duration: %" PRIu32,
static_cast<int8_t>(this->dout_pin_), this->buffer_duration_ms_);
" Buffer duration: %" PRIu32 "\n"
" Keep alive: %s",
static_cast<int8_t>(this->dout_pin_), this->buffer_duration_ms_, YESNO(this->keep_alive_));
if (this->timeout_.has_value()) {
ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value());
}
@@ -105,7 +106,13 @@ void I2SAudioSpeakerBase::loop() {
vTaskDelete(this->speaker_task_handle_);
this->speaker_task_handle_ = nullptr;
this->stop_i2s_driver_();
// The task raises this bit before it exits, possibly a loop pass before TASK_STOPPED is seen, so read it
// live here; a channel the task could not bring up is rebuilt next time rather than kept
const bool task_failed = xEventGroupGetBits(this->event_group_) & SpeakerEventGroupBits::ERR_ESP_NO_MEM;
if (task_failed) {
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
}
this->stop_i2s_driver_(this->keep_alive_ && !task_failed);
// ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at
// the top of loop(): the audio source's task can raise a start at any point above, including
// during stop_i2s_driver_(), and nothing would ever re-issue it.
@@ -121,11 +128,6 @@ void I2SAudioSpeakerBase::loop() {
this->state_ = speaker::STATE_STOPPED;
}
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
}
// Handle the speaker's state
switch (this->state_) {
case speaker::STATE_STARTING:
@@ -133,8 +135,8 @@ void I2SAudioSpeakerBase::loop() {
break;
}
// Still starting up or winding down from a previous run
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
// Still winding down from a previous run
if (this->speaker_task_handle_ != nullptr) {
break;
}
@@ -154,7 +156,7 @@ void I2SAudioSpeakerBase::loop() {
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error(1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt
}
break;
case speaker::STATE_RUNNING: // Intentional fallthrough
@@ -270,31 +272,25 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c
err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to initialize I2S channel");
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
this->delete_channel_();
this->parent_->unlock();
return err;
}
// Both queues are drained by the speaker task before it preloads, whether the channel is new or kept
if (this->i2s_event_queue_ == nullptr) {
this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t));
} else {
// Reset queue to clear any stale events from previous task
xQueueReset(this->i2s_event_queue_);
}
// Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue
// so a fully-saturated DMA pipeline cannot overflow either side before drain.
if (this->write_records_queue_ == nullptr) {
this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t));
} else {
xQueueReset(this->write_records_queue_);
}
if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)");
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
this->delete_channel_();
this->parent_->unlock();
return ESP_ERR_NO_MEM;
}
@@ -302,22 +298,30 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c
return ESP_OK;
}
void I2SAudioSpeakerBase::stop_i2s_driver_() {
void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) {
if (this->tx_handle_ != nullptr) {
i2s_channel_disable(this->tx_handle_);
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
this->park_dout_pin_();
// A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and
// clock reconfiguration is the audible pop
if (!keep_channel) {
this->delete_channel_();
}
}
this->parent_->unlock();
}
void I2SAudioSpeakerBase::delete_channel_() {
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
this->park_dout_pin_();
}
bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
int64_t now = esp_timer_get_time();
@@ -54,6 +54,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; }
void set_timeout(uint32_t ms) { this->timeout_ = ms; }
void set_keep_alive(bool keep_alive) { this->keep_alive_ = keep_alive; }
void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; }
/// @brief Get the I2S TX channel handle
@@ -126,12 +127,15 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
size_t event_queue_size);
/// @brief Stops the I2S driver and unlocks the I2S port
void stop_i2s_driver_();
/// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated
void stop_i2s_driver_(bool keep_channel);
/// @brief Detaches dout from the I2S signal and drives it low
void park_dout_pin_();
/// @brief Deletes the I2S channel and parks dout; the caller holds the bus lock
void delete_channel_();
/// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup.
virtual void on_task_stopped() {}
@@ -177,6 +181,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
optional<uint32_t> timeout_;
bool pause_state_{false};
bool keep_alive_{false};
// Smooths software gain changes. The main loop posts targets, the speaker task processes;
// GainRamp's mailbox makes that safe. The main loop is the only poster.
@@ -75,6 +75,9 @@ void I2SAudioSpeaker::dump_config() {
void I2SAudioSpeaker::run_speaker_task() {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
// Stale records from the previous run would pair with the wrong DMA events
xQueueReset(this->i2s_event_queue_);
xQueueReset(this->write_records_queue_);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT;
// Ensure ring buffer duration is at least the duration of all DMA buffers
@@ -389,8 +392,6 @@ void I2SAudioSpeaker::run_speaker_task() {
}
esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) {
this->current_stream_info_ = audio_stream_info;
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
// Can't reconfigure I2S bus, so the sample rate must match the configured value
ESP_LOGE(TAG, "Incompatible stream settings");
@@ -428,6 +429,16 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
return ESP_ERR_INVALID_STATE;
}
// Reuse a kept channel while the stream format matches
if (this->tx_handle_ != nullptr) {
if (this->current_stream_info_ == audio_stream_info) {
return ESP_OK;
}
this->delete_channel_();
}
this->current_stream_info_ = audio_stream_info;
// The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info.
uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_);
+2
View File
@@ -142,6 +142,8 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas
}
#endif // USE_ESP8266
uint8_t Logger::get_log_level(const char *tag) { return this->level_for(tag); }
inline uint8_t Logger::level_for(const char *tag) {
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
// Skip the lookup when no per-tag levels are set
+10
View File
@@ -178,6 +178,8 @@ class Logger final : public Component {
void set_log_level(const char *tag, uint8_t log_level);
#endif
uint8_t get_log_level() { return this->current_level_; }
/// The level in force for a tag: its own when one is set, else the default
uint8_t get_log_level(const char *tag);
// ========== INTERNAL METHODS ==========
// (In most use cases you won't need these)
@@ -511,6 +513,14 @@ class LoggerMessageTrigger final : public Trigger<uint8_t, const char *, const c
#endif
};
#ifdef USE_CRASH_HANDLER
/// Whether a crash record from before this boot is waiting to be reported
bool crash_log_pending();
/// Log the previous crash for a client that just subscribed to the logs. On the ESP32 the record
/// is kept until a client has seen it, so this call also clears it there.
void log_crash_to_client();
#endif
} // namespace esphome::logger
// Platform-specific inline implementations of write_msg_()
@@ -162,5 +162,16 @@ const LogString *Logger::get_uart_selection_() {
}
}
#ifdef USE_CRASH_HANDLER
bool crash_log_pending() { return esp32::crash_handler_has_data(); }
void log_crash_to_client() {
// A level that drops the lines would throw the record away unseen; keep it for a client that can see it
if (global_logger->get_log_level(esp32::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR)
return;
esp32::crash_handler_log();
esp32::crash_handler_clear();
}
#endif
} // namespace esphome::logger
#endif
@@ -48,6 +48,11 @@ const LogString *Logger::get_uart_selection_() {
#endif
}
#ifdef USE_CRASH_HANDLER
bool crash_log_pending() { return esp8266::crash_handler_has_data(); }
void log_crash_to_client() { esp8266::crash_handler_log(); }
#endif
} // namespace esphome::logger
#if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1)
+5
View File
@@ -49,5 +49,10 @@ const LogString *Logger::get_uart_selection_() {
}
}
#ifdef USE_CRASH_HANDLER
bool crash_log_pending() { return rp2::crash_handler_has_data(); }
void log_crash_to_client() { rp2::crash_handler_log(); }
#endif
} // namespace esphome::logger
#endif // USE_RP2
+43 -65
View File
@@ -16,6 +16,7 @@
#include "lwip/dns.h"
#include "lwip/err.h"
#include "mqtt_component.h"
#include "mqtt_topic_match.h"
#ifdef USE_API
#include "esphome/components/api/api_server.h"
@@ -49,6 +50,13 @@ MQTTClientComponent::MQTTClientComponent() {
}
// Connection
// Room for "esphome/<kind>/<name>" with the longest kind, "discover"
static constexpr size_t ESPHOME_TOPIC_BUFFER_SIZE = sizeof("esphome/discover/") + ESPHOME_DEVICE_NAME_MAX_LEN;
static void format_esphome_topic(char (&topic)[ESPHOME_TOPIC_BUFFER_SIZE], const char *kind) {
buf_append_printf(topic, sizeof(topic), 0, "esphome/%s/%s", kind, App.get_name().c_str());
}
void MQTTClientComponent::setup() {
this->mqtt_backend_.set_on_message(
[this](const char *topic, const char *payload, size_t len, size_t index, size_t total) {
@@ -78,6 +86,13 @@ void MQTTClientComponent::setup() {
this, [](void *self, uint8_t level, const char *tag, const char *message, size_t message_len) {
static_cast<MQTTClientComponent *>(self)->on_log(level, tag, message, message_len);
});
#ifdef USE_MQTT_CRASH_LOG
// `esphome logs` announces itself here, like an API client's log subscription, and the record stays
// loggable until the next boot, so the offer does too. A log topic that drops errors would swallow the
// replay, so it is not offered. The topic is rebuilt from the device name when needed, so nothing is
// stored for it.
this->crash_log_offered_ = logger::crash_log_pending() && this->log_level_ >= ESPHOME_LOG_LEVEL_ERROR;
#endif
}
#endif
@@ -87,10 +102,8 @@ void MQTTClientComponent::setup() {
2);
// Format topic on stack - subscribe() copies it
// "esphome/ping/" (13) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1)
constexpr size_t ping_topic_buffer_size = 13 + ESPHOME_DEVICE_NAME_MAX_LEN + 1;
char ping_topic[ping_topic_buffer_size];
buf_append_printf(ping_topic, sizeof(ping_topic), 0, "esphome/ping/%s", App.get_name().c_str());
char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(ping_topic, "ping");
this->subscribe(
ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2);
}
@@ -105,10 +118,8 @@ void MQTTClientComponent::send_device_info_() {
return;
}
// Format topic on stack to avoid heap allocation
// "esphome/discover/" (17) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1)
constexpr size_t topic_buffer_size = 17 + ESPHOME_DEVICE_NAME_MAX_LEN + 1;
char topic[topic_buffer_size];
buf_append_printf(topic, sizeof(topic), 0, "esphome/discover/%s", App.get_name().c_str());
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(topic, "discover");
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
this->publish_json(
@@ -354,6 +365,14 @@ void MQTTClientComponent::check_connected() {
delay(100); // NOLINT
this->resubscribe_subscriptions_();
#ifdef USE_MQTT_CRASH_LOG
// Best effort: a refused subscription is tried again at the next connection
if (this->crash_log_offered_) {
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(topic, "logs");
this->subscribe_(topic, 0);
}
#endif
this->send_device_info_();
for (MQTTComponent *component : this->children_)
@@ -461,6 +480,19 @@ void MQTTClientComponent::resubscribe_subscriptions_() {
}
}
#ifdef USE_MQTT_CRASH_LOG
void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) {
// Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash
// log for every line it logs
if (!this->crash_log_offered_ || !payload.empty())
return;
char logs_topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(logs_topic, "logs");
if (topic == logs_topic)
logger::log_crash_to_client();
}
#endif
void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed,
uint8_t qos) {
auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{
@@ -580,63 +612,6 @@ void MQTTClientComponent::disable() {
this->on_shutdown();
}
/** Check if the message topic matches the given subscription topic
*
* INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings.
*
* @param message The message topic that was received from the MQTT server. Note: this must not contain
* wildcard characters as mandated by the MQTT spec.
* @param subscription The subscription topic we are matching against.
* @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$".
* @param past_separator Are we past the first '/' topic separator.
* @return true if the subscription topic matches the message topic, false otherwise.
*/
static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
// Reached end of both strings at the same time, this means we have a successful match
if (*message == '\0' && *subscription == '\0')
return true;
// Either the message or the subscribe are at the end. This means they don't match.
if (*message == '\0' || *subscription == '\0')
return false;
bool do_wildcards = is_normal || past_separator;
if (*subscription == '+' && do_wildcards) {
// single level wildcard
// consume + from subscription
subscription++;
// consume everything from message until '/' found or end of string
while (*message != '\0' && *message != '/') {
message++;
}
// after this, both pointers will point to a '/' or to the end of the string
return topic_match(message, subscription, is_normal, true);
}
if (*subscription == '#' && do_wildcards) {
// multilevel wildcard - MQTT mandates that this must be at end of subscribe topic
return true;
}
// this handles '/' and normal characters at the same time.
if (*message != *subscription)
return false;
past_separator = past_separator || *subscription == '/';
// consume characters
subscription++;
message++;
return topic_match(message, subscription, is_normal, past_separator);
}
static bool topic_match(const char *message, const char *subscription) {
return topic_match(message, subscription, *message != '\0' && *message != '$', false);
}
void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) {
#ifdef USE_ESP8266
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
@@ -658,6 +633,9 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string
// DO NOT REMOVE THIS DEFER without understanding the above. It may appear to work
// in simple tests but will cause crashes with complex automations.
this->defer([this, topic, payload]() {
#endif
#ifdef USE_MQTT_CRASH_LOG
this->handle_crash_log_request_(topic, payload);
#endif
// Indexed with the count taken up front: a callback may subscribe, which can reallocate the
// vector, and the new entry waits for the next message.
+11
View File
@@ -24,6 +24,11 @@
#include <vector>
// The crash log replay needs the logger, which mqtt does not depend on
#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER)
#define USE_MQTT_CRASH_LOG
#endif
namespace esphome::mqtt {
/** Callback for MQTT events.
@@ -316,6 +321,9 @@ class MQTTClientComponent final : public Component {
callback(topic, doc.as<JsonObject>());
}
void erase_removed_subscriptions_();
#ifdef USE_MQTT_CRASH_LOG
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
#endif
void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
void resubscribe_subscriptions_();
@@ -359,6 +367,9 @@ class MQTTClientComponent final : public Component {
bool dns_resolved_{false};
bool dns_resolve_error_{false};
bool enable_on_boot_{true};
#ifdef USE_MQTT_CRASH_LOG
bool crash_log_offered_{false};
#endif
std::vector<MQTTComponent *> children_;
uint32_t reboot_timeout_{300000};
uint32_t connect_begin_;
@@ -0,0 +1,28 @@
#pragma once
namespace esphome::mqtt {
/// True if the message topic matches the subscription, with + and # wildcards.
/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics.
inline bool topic_match(const char *message, const char *subscription) {
// First-level wildcards do not match topics that begin with '$'
bool wildcards = *message != '$';
while (*message != '\0' && *subscription != '\0') {
if (wildcards && *subscription == '#')
return true;
if (wildcards && *subscription == '+') {
subscription++;
while (*message != '\0' && *message != '/')
message++;
continue;
}
if (*message != *subscription)
return false;
wildcards |= *subscription == '/';
message++;
subscription++;
}
return *message == *subscription; // a match only if both ended
}
} // namespace esphome::mqtt
@@ -38,6 +38,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
.reserved = {},
};
int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);
+1
View File
@@ -392,6 +392,7 @@ async def to_code(config: ConfigType) -> None:
"USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]
) # back-compat alias
cg.add_define("USE_RP2_CRASH_HANDLER")
cg.add_define("USE_CRASH_HANDLER")
cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias
_configure_lwip()
@@ -418,6 +418,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api:
msg.usb.product_id = scratch.product_id;
msg.usb.bcd_device = scratch.bcd_device;
msg.usb.interface_number = this->usb_channel_->get_interface_number();
msg.usb.interface_class = this->usb_channel_->get_interface_class();
msg.usb.port_number = this->usb_channel_->get_port_number();
msg.manufacturer = StringRef(scratch.manufacturer);
msg.product = StringRef(scratch.product);
msg.serial_number = StringRef(scratch.serial_number);
-8
View File
@@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo
}
}
std::vector<CdcEps> USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) {
auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl);
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare
for (auto &cdc_dev : result) {
cdc_dev.interrupt_interface_number = 0xFF;
}
return result;
}
} // namespace esphome::usb_uart
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
+5 -5
View File
@@ -88,11 +88,11 @@ std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev
ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed");
continue;
}
if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) {
cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}});
} else {
cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}});
}
CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN
? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}
: CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber};
eps.bulk_interface_class = data_desc->bInterfaceClass;
cdc_devs.push_back(eps);
}
return cdc_devs;
}
+1
View File
@@ -217,6 +217,7 @@ static optional<CdcEps> get_uart(const usb_config_desc_t *config_desc, uint8_t i
}
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
return eps;
}
+4 -1
View File
@@ -189,7 +189,10 @@ std::vector<CdcEps> USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev
}
if (in_ep && out_ep) {
cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber});
CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber};
eps.bulk_interface_class = intf->bInterfaceClass;
eps.interrupt_interface_class = intf->bInterfaceClass;
cdc_devs.push_back(eps);
break; // PL2303 is single-port
}
}
+4 -2
View File
@@ -41,14 +41,17 @@ static optional<CdcEps> get_cdc(const usb_config_desc_t *config_desc, uint8_t in
if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) {
eps.notify_ep = ep;
eps.interrupt_interface_number = intf_desc->bInterfaceNumber;
eps.interrupt_interface_class = intf_desc->bInterfaceClass;
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN &&
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
eps.in_ep = ep;
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) &&
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
eps.out_ep = ep;
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
eps.bulk_interface_class = intf_desc->bInterfaceClass;
} else {
ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes);
continue;
@@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() {
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
if (!this->claim_comm_interface_) {
ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)",
channel->cdc_dev_.interrupt_interface_number);
ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number);
} else {
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
channel->cdc_dev_.interrupt_interface_number, 0);
+19 -2
View File
@@ -40,6 +40,9 @@ struct CdcEps {
// must remain valid even when the interface itself is not claimed.
uint8_t interrupt_interface_number{0xFF};
bool interrupt_interface_claimed{false};
// bInterfaceClass of each interface above, which decides how a host binds the port
uint8_t bulk_interface_class{0};
uint8_t interrupt_interface_class{0};
};
enum CH34xChipType : uint8_t {
@@ -187,6 +190,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
: this->cdc_dev_.bulk_interface_number;
}
/// bInterfaceClass of the interface `get_interface_number()` names
uint8_t get_interface_class() const {
return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class
: this->cdc_dev_.bulk_interface_class;
}
/// Index of this port among the ports on its interface. Every driver here exposes one
/// port per interface; a multiplexed driver would index its channels.
uint8_t get_port_number() const { return 0; }
protected:
// Not directly instantiable; construct a concrete channel type instead.
USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
@@ -318,13 +331,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm {
};
class USBUartTypeCH34X : public USBUartTypeCdcAcm {
public:
USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {}
USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The
// comm interface is left in place unclaimed: on the CDC-style chips it is still the
// interface a host binds, and so the one the port is identified by.
this->claim_comm_interface_ = false;
}
void dump_config() override;
protected:
bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override;
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
private:
CH34xChipType chiptype_{CHIP_UNKNOWN};
+1
View File
@@ -58,6 +58,7 @@
#define USE_CLIMATE_VISUAL_OVERRIDES
#define USE_CONTROLLER_REGISTRY
#define USE_COVER
#define USE_CRASH_HANDLER
#define USE_DATETIME
#define USE_DATETIME_DATE
#define USE_DATETIME_DATETIME
+32 -6
View File
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
import paho.mqtt.client as mqtt
from esphome.const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CERTIFICATE_AUTHORITY,
CONF_CLIENT_CERTIFICATE,
@@ -21,7 +22,9 @@ from esphome.const import (
CONF_MQTT,
CONF_NAME,
CONF_PASSWORD,
CONF_PAYLOAD,
CONF_PORT,
CONF_RETAIN,
CONF_SKIP_CERT_CN_CHECK,
CONF_TOPIC,
CONF_TOPIC_PREFIX,
@@ -319,13 +322,36 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None):
return 1
_LOGGER.info("Starting log output from %s", topic)
def on_message(client, userdata, msg):
time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]")
payload = msg.payload.decode(errors="backslashreplace")
message = time_ + payload
safe_print(message)
logs_topic = f"esphome/logs/{config[CONF_ESPHOME][CONF_NAME]}"
# Ask when the retained birth message reports online: a request sent while the device is down is
# lost, and only a retained message tells a CLI that connects later that the device is already up.
# An empty payload deletes the retained message instead, so it counts as no birth message.
birth = config.get(CONF_MQTT, {}).get(CONF_BIRTH_MESSAGE) or {}
birth_payload = birth.get(CONF_PAYLOAD)
birth_topic = birth.get(CONF_TOPIC) if birth_payload else None
ask_on_connect = birth_topic is None or not birth[CONF_RETAIN]
return initialize(config, [topic], on_message, None, username, password, client_id)
def request_crash_log(client):
_LOGGER.debug("Requesting the crash log via topic: %s", logs_topic)
client.publish(logs_topic, None, retain=False)
def on_message(client, userdata, msg):
payload = msg.payload.decode(errors="backslashreplace")
if msg.topic == birth_topic and payload == birth_payload:
request_crash_log(client)
# The birth topic may sit inside the log subscription, so print by what was asked for
if mqtt.topic_matches_sub(topic, msg.topic):
time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]")
safe_print(time_ + payload)
def on_connect(client, userdata, flags, return_code):
if ask_on_connect:
request_crash_log(client)
subscriptions = [topic] if birth_topic is None else [topic, birth_topic]
return initialize(
config, subscriptions, on_message, on_connect, username, password, client_id
)
def clear_topic(config, topic, username=None, password=None, client_id=None):
+2 -2
View File
@@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.3
platformdirs==4.12.4 # native esp-idf toolchain global cache dir
platformdirs==4.13.0 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.3
+1 -1
View File
@@ -1,4 +1,4 @@
pylint==4.1.2
pylint==4.1.3
flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
@@ -0,0 +1,16 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
keep_alive: true
@@ -0,0 +1,19 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
keep_alive: true
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO13
@@ -0,0 +1,22 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
keep_alive: true
microphone:
- platform: i2s_audio
i2s_din_pin: GPIO34
adc_type: external
pdm: false
@@ -0,0 +1,20 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
spdif_mode: true
use_apll: true
sample_rate: 48000
channel: stereo
keep_alive: true
@@ -0,0 +1,36 @@
"""Exercise the I2S speaker keep_alive validation."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.core import CORE
def test_keep_alive_generates_setter(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = generate_main(component_config_path("keep_alive.yaml"))
assert "set_keep_alive(true)" in source
@pytest.mark.parametrize(
("config_file", "message"),
[
("keep_alive_shared_bus.yaml", "needs its own I2S bus"),
("keep_alive_shared_mic.yaml", "needs its own I2S bus"),
("keep_alive_spdif.yaml", "SPDIF mode does not support 'keep_alive'"),
],
)
def test_keep_alive_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
config_file: str,
message: str,
) -> None:
CORE.config_path = component_config_path(config_file)
assert read_config({}) is None
assert message in capsys.readouterr().out
@@ -4,6 +4,7 @@
#include "esphome/components/friedrich/friedrich.h"
#include "esphome/components/remote_base/aeha_protocol.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
namespace esphome::friedrich::testing {
@@ -41,7 +42,12 @@ struct Fixture {
TestFriedrich sut;
CapturingTransmitter transmitter;
Fixture() { this->sut.set_transmitter(&this->transmitter); }
Fixture() {
// Climate tests share one preference key, so clear what an earlier test saved or setup restores it
if (global_preferences != nullptr)
global_preferences->reset();
this->sut.set_transmitter(&this->transmitter);
}
// The last transmission decoded, or an empty frame if it could not be decoded
AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); }
+103
View File
@@ -0,0 +1,103 @@
#include <gtest/gtest.h>
#include <random>
#include <string>
#include "esphome/components/mqtt/mqtt_topic_match.h"
namespace esphome::mqtt::testing {
// The previous recursive implementation, kept to check the loop matches it
static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
if (*message == '\0' && *subscription == '\0')
return true;
if (*message == '\0' || *subscription == '\0')
return false;
bool do_wildcards = is_normal || past_separator;
if (*subscription == '+' && do_wildcards) {
subscription++;
while (*message != '\0' && *message != '/')
message++;
return reference_match(message, subscription, is_normal, true);
}
if (*subscription == '#' && do_wildcards)
return true;
if (*message != *subscription)
return false;
past_separator = past_separator || *subscription == '/';
return reference_match(message + 1, subscription + 1, is_normal, past_separator);
}
static bool reference_match(const char *message, const char *subscription) {
return reference_match(message, subscription, *message != '\0' && *message != '$', false);
}
TEST(MQTTTopicMatch, Exact) {
EXPECT_TRUE(topic_match("a/b/c", "a/b/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/b"));
EXPECT_FALSE(topic_match("a/b", "a/b/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/b/d"));
}
TEST(MQTTTopicMatch, SingleLevelWildcard) {
EXPECT_TRUE(topic_match("a/b/c", "a/+/c"));
EXPECT_TRUE(topic_match("a/b/c", "+/+/+"));
EXPECT_TRUE(topic_match("a//c", "a/+/c"));
EXPECT_FALSE(topic_match("a/b/c", "a/+"));
EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c"));
}
TEST(MQTTTopicMatch, MultiLevelWildcard) {
EXPECT_TRUE(topic_match("a/b/c", "a/#"));
EXPECT_TRUE(topic_match("a/b/c", "#"));
EXPECT_TRUE(topic_match("a/b/c", "a/+/#"));
EXPECT_FALSE(topic_match("b/c", "a/#"));
}
TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) {
EXPECT_FALSE(topic_match("$SYS/broker", "#"));
EXPECT_FALSE(topic_match("$SYS/broker", "+/broker"));
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#"));
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+"));
}
TEST(MQTTTopicMatch, EdgeCases) {
EXPECT_TRUE(topic_match("", ""));
EXPECT_FALSE(topic_match("a", ""));
// Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level
EXPECT_FALSE(topic_match("a/b", "a/b/#"));
EXPECT_FALSE(topic_match("a/", "a/+"));
EXPECT_TRUE(topic_match("a/$x", "a/+"));
}
// Recursion overflowed the ESP32 loop stack at about 250 characters
TEST(MQTTTopicMatch, LongTopic) {
std::string topic(4096, 'x');
topic[100] = '/';
EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str()));
std::string other = topic;
other.back() = 'y';
EXPECT_FALSE(topic_match(topic.c_str(), other.c_str()));
}
TEST(MQTTTopicMatch, MatchesPreviousImplementation) {
// Small alphabet so wildcards, separators and '$' meet often
static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'};
static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'};
std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
auto random_string = [&rng](const char *chars, size_t count) {
std::string s(rng() % 8, ' ');
for (char &c : s)
c = chars[rng() % count];
return s;
};
for (int i = 0; i < 20000; i++) {
std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS));
std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS));
ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()),
reference_match(message.c_str(), subscription.c_str()))
<< "message='" << message << "' subscription='" << subscription << "'";
}
}
} // namespace esphome::mqtt::testing
+16
View File
@@ -0,0 +1,16 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The harness gives a multi-instance component an empty list and never calls its to_code,
# so take a single empty config instead and emit what the proxy messages and handlers
# need. No proxy is configured, so the count is 0, which keeps the capability messages as
# the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only.
manifest.multi_conf = False
async def to_code_testing(config):
cg.add_define("USE_SERIAL_PROXY")
cg.add_define("SERIAL_PROXY_COUNT", 0)
manifest.to_code = to_code_testing
@@ -0,0 +1,51 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/proto.h"
namespace esphome::api::testing {
// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check.
static std::vector<uint8_t> encode(const UsbDeviceDescriptor &msg) {
const uint32_t size = msg.calculate_size();
APIBuffer buf;
EXPECT_TRUE(buf.resize(size == 0 ? 1 : size));
uint8_t *pos = buf.data();
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = buf.data() + buf.size();
#endif
uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG);
EXPECT_EQ(static_cast<uint32_t>(end - buf.data()), size);
return {buf.data(), end};
}
TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) {
UsbDeviceDescriptor msg;
msg.vendor_id = 0x10C4;
msg.product_id = 0xEA60;
msg.bcd_device = 0x0100;
msg.interface_number = 1;
msg.interface_class = 0xFF;
msg.port_number = 2;
const std::vector<uint8_t> expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80,
0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02};
EXPECT_EQ(encode(msg), expected);
}
TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) {
// The new fields are additive with zero defaults, so an old client sees the message it always did
UsbDeviceDescriptor msg;
msg.vendor_id = 0x303A;
msg.product_id = 0x1001;
msg.interface_number = 1;
const std::vector<uint8_t> expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01};
EXPECT_EQ(encode(msg), expected);
}
} // namespace esphome::api::testing
+105 -2
View File
@@ -9,9 +9,112 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME
from esphome.const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_ESPHOME,
CONF_MQTT,
CONF_NAME,
CONF_PAYLOAD,
CONF_RETAIN,
CONF_TOPIC,
)
from esphome.core import EsphomeError
from esphome.mqtt import get_esphome_device_ip
from esphome.mqtt import get_esphome_device_ip, show_logs
@pytest.mark.parametrize(
"birth",
[
None,
{CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "online", CONF_RETAIN: False},
{CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "", CONF_RETAIN: True},
],
ids=["no birth message", "unretained birth message", "empty birth payload"],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_asks_on_connect(
mock_initialize: MagicMock, birth: dict | None
) -> None:
"""Without a retained birth message nothing tells a later CLI the device is up, so it asks on connect."""
config = _discovery_config()
if birth is not None:
config[CONF_MQTT][CONF_BIRTH_MESSAGE] = birth
show_logs(config)
on_connect = mock_initialize.call_args.args[3]
client = MagicMock()
on_connect(client, None, None, 0)
client.publish.assert_called_once_with(
"esphome/logs/test-device", None, retain=False
)
if birth is None or not birth[CONF_PAYLOAD]:
assert mock_initialize.call_args.args[1] == ["test-device/debug"]
@patch("esphome.mqtt.safe_print")
@patch("esphome.mqtt.initialize")
def test_show_logs_requests_crash_log_when_device_reports_online(
mock_initialize: MagicMock, mock_print: MagicMock
) -> None:
"""With a retained birth message the request follows the device coming online, not the CLI connecting."""
config = _discovery_config()
config[CONF_MQTT][CONF_BIRTH_MESSAGE] = {
CONF_TOPIC: "test-device/status",
CONF_PAYLOAD: "online",
CONF_RETAIN: True,
}
show_logs(config)
assert mock_initialize.call_args.args[1] == [
"test-device/debug",
"test-device/status",
]
on_message = mock_initialize.call_args.args[2]
on_connect = mock_initialize.call_args.args[3]
client = MagicMock()
on_connect(client, None, None, 0)
client.publish.assert_not_called()
on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline"))
client.publish.assert_not_called()
on_message(client, None, MagicMock(topic="test-device/status", payload=b"online"))
client.publish.assert_called_once_with(
"esphome/logs/test-device", None, retain=False
)
mock_print.assert_not_called()
on_message(
client, None, MagicMock(topic="test-device/debug", payload=b"[I][app]: hi")
)
assert mock_print.call_args.args[0].endswith("[I][app]: hi")
client.publish.assert_called_once()
@patch("esphome.mqtt.safe_print")
@patch("esphome.mqtt.initialize")
def test_show_logs_prints_a_birth_message_inside_the_log_subscription(
mock_initialize: MagicMock, mock_print: MagicMock
) -> None:
"""A birth topic covered by the log topic is still printed, and still requests the crash log."""
config = _discovery_config()
config[CONF_MQTT][CONF_BIRTH_MESSAGE] = {
CONF_TOPIC: "test-device/status",
CONF_PAYLOAD: "online",
CONF_RETAIN: True,
}
show_logs(config, topic="test-device/#")
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
on_message(client, None, MagicMock(topic="test-device/status", payload=b"online"))
client.publish.assert_called_once_with(
"esphome/logs/test-device", None, retain=False
)
assert mock_print.call_args.args[0].endswith("online")
def test_get_esphome_device_ip_empty_broker() -> None: