Compare commits

...
Author SHA1 Message Date
J. Nick Koston 9d226de405 Merge branch 'dev' into autorepeat-progmem-timing 2026-10-14 10:53:08 -10:00
J. Nick Koston ac74796263 [friedrich] Clear saved preferences in the test fixture (#20770) 2026-10-14 10:52:13 -10:00
c56bb14037 [mqtt] Print crash info over MQTT (#17935)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 10:39:55 -10:00
J. Nick Koston e8eba14c58 Merge remote-tracking branch 'origin/dev' into autorepeat-progmem-timing 2026-10-14 10:39:18 -10:00
J. Nick Koston d0b0cd6e13 Merge remote-tracking branch 'origin/dev' into HEAD 2026-10-09 18:27:34 -10:00
J. Nick Koston 18e93a96fc Merge remote-tracking branch 'origin/dev' into autorepeat-progmem-timing 2026-10-04 18:06:30 -05:00
J. Nick Koston 01f1139476 Also guard the struct size so an added field fails the build 2026-10-03 05:48:38 -05:00
J. Nick Koston 053cb337aa Split the field type guards into one static_assert per field
clang-tidy flags the chained is_same_v checks as a redundant expression.
2026-10-02 21:42:31 -05:00
J. Nick Koston 3ac5918ec8 [core] Let shared_progmem_array emit an unshared array
Identical lambdas can carry separate static state, so callers need a way
to keep such tables apart while still getting a collision free name.
2026-10-02 21:19:12 -05:00
J. Nick Koston bf5d095cf6 End the autorepeat table with a never-run step so the filter stores no count 2026-10-02 20:59:52 -05:00
J. Nick Koston eae1b99232 Guard the AutorepeatFilterTiming field types read from flash 2026-10-02 20:59:52 -05:00
J. Nick Koston 15936f9a55 [core] Emit shared PROGMEM arrays at namespace scope
A setup() local table could only be reused from the scope of its first
use. The arrays are now globals. A lambda in a table only references
variables that codegen has already declared, so writing the global
straight away keeps the order valid.
2026-10-02 20:03:21 -05:00
J. Nick Koston aac085c5c7 Build the autorepeat timing table with cg.shared_progmem_array 2026-10-02 19:49:24 -05:00
J. Nick Koston fe66ac546a [core] Add cg.shared_progmem_array for deduplicated PROGMEM tables
Emits one PROGMEM array per distinct type and contents and returns the
same variable to later callers. The array name is made unique against
every config id and registered variable, so a generated table can never
collide with a user id.
2026-10-02 19:48:06 -05:00
J. Nick Koston a8e5fd0d2f Build the default autorepeat timing from the schema and tidy the tests 2026-10-02 19:07:18 -05:00
J. Nick Koston 93afb0d092 [binary_sensor] Keep autorepeat filter timings in a shared PROGMEM table 2026-10-02 18:53:18 -05:00
23 changed files with 383 additions and 120 deletions
+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
+40 -43
View File
@@ -149,6 +149,7 @@ DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter)
AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter)
AutorepeatFilterTiming = binary_sensor_ns.struct("AutorepeatFilterTiming")
LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter)
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter)
@@ -236,53 +237,49 @@ async def delayed_off_filter_to_code(config, filter_id):
return var
AUTOREPEAT_TIMING_SCHEMA = cv.Schema(
{
cv.Optional(
CONF_DELAY, default=DEFAULT_DELAY
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_OFF, default=DEFAULT_TIME_OFF
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_ON, default=DEFAULT_TIME_ON
): cv.positive_time_period_milliseconds,
}
)
@register_filter(
"autorepeat",
AutorepeatFilter,
cv.All(
cv.ensure_list(
{
cv.Optional(
CONF_DELAY, default=DEFAULT_DELAY
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_OFF, default=DEFAULT_TIME_OFF
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TIME_ON, default=DEFAULT_TIME_ON
): cv.positive_time_period_milliseconds,
}
),
cv.Length(max=254),
),
cv.All(cv.ensure_list(AUTOREPEAT_TIMING_SCHEMA), cv.Length(max=254)),
)
async def autorepeat_filter_to_code(config, filter_id):
if len(config) > 0:
timings = [
cg.StructInitializer(
cg.MockObj("AutorepeatFilterTiming", "esphome::binary_sensor::"),
("delay", conf[CONF_DELAY]),
("time_off", conf[CONF_TIME_OFF]),
("time_on", conf[CONF_TIME_ON]),
)
for conf in config
]
else:
timings = [
cg.StructInitializer(
cg.MockObj("AutorepeatFilterTiming", "esphome::binary_sensor::"),
("delay", cv.time_period_str_unit(DEFAULT_DELAY).total_milliseconds),
(
"time_off",
cv.time_period_str_unit(DEFAULT_TIME_OFF).total_milliseconds,
),
(
"time_on",
cv.time_period_str_unit(DEFAULT_TIME_ON).total_milliseconds,
),
)
]
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
async def autorepeat_filter_to_code(config: list[ConfigType], filter_id: ID) -> MockObj:
config = config or [AUTOREPEAT_TIMING_SCHEMA({})]
timings = [
cg.StructInitializer(
AutorepeatFilterTiming,
("delay", conf[CONF_DELAY].total_milliseconds),
("time_off", conf[CONF_TIME_OFF].total_milliseconds),
("time_on", conf[CONF_TIME_ON].total_milliseconds),
)
for conf in config
]
# A never-run delay ends the table, so the filter stores no count.
timings.append(
cg.StructInitializer(
AutorepeatFilterTiming, ("delay", cg.RawExpression("SCHEDULER_DONT_RUN"))
)
)
table = cg.shared_progmem_array(
"binary_sensor_autorepeat_timings",
AutorepeatFilterTiming,
cg.ArrayInitializer(*timings),
)
return cg.new_Pvariable(filter_id, table)
@register_filter("lambda", LambdaFilter, cv.returning_lambda)
+9 -9
View File
@@ -63,12 +63,12 @@ optional<bool> DelayedOffFilter::new_value(bool value) {
optional<bool> InvertFilter::new_value(bool value) { return !value; }
// AutorepeatFilterBase
// AutorepeatFilter
// Two independent timers per instance, keyed off two stable addresses inside
// the filter: `this` for the timing-step timer, `&active_timing_` for the
// on/off timer. Both are unique per instance and don't collide with anything
// else, so the self-keyed scheduler API is sufficient.
optional<bool> AutorepeatFilterBase::new_value(bool value) {
optional<bool> AutorepeatFilter::new_value(bool value) {
if (value) {
if (this->active_timing_ != 0)
return {};
@@ -82,18 +82,18 @@ optional<bool> AutorepeatFilterBase::new_value(bool value) {
}
}
void AutorepeatFilterBase::next_timing_() {
if (this->active_timing_ < this->timings_count_) {
App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); });
}
if (this->active_timing_ <= this->timings_count_) {
this->active_timing_++;
void AutorepeatFilter::next_timing_() {
const uint32_t delay = this->timings_[this->active_timing_].delay;
if (delay != SCHEDULER_DONT_RUN) {
App.scheduler.set_timeout(this, delay, [this]() { this->next_timing_(); });
}
// Only reached with active_timing_ at most the end-marker index, so this never passes it.
this->active_timing_++;
if (this->active_timing_ == 2)
this->next_value_(false);
}
void AutorepeatFilterBase::next_value_(bool val) {
void AutorepeatFilter::next_value_(bool val) {
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
this->output(val);
App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off,
+20 -25
View File
@@ -1,9 +1,9 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR_FILTER
#include <array>
#include <type_traits>
#ifdef USE_BINARY_SENSOR_FILTER
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
@@ -87,39 +87,34 @@ struct AutorepeatFilterTiming {
uint32_t time_off;
uint32_t time_on;
};
// Read straight from flash on ESP8266, so every field must stay a word.
static_assert(std::is_same_v<decltype(AutorepeatFilterTiming::delay), uint32_t>,
"AutorepeatFilterTiming fields must stay uint32_t");
static_assert(std::is_same_v<decltype(AutorepeatFilterTiming::time_off), uint32_t>,
"AutorepeatFilterTiming fields must stay uint32_t");
static_assert(std::is_same_v<decltype(AutorepeatFilterTiming::time_on), uint32_t>,
"AutorepeatFilterTiming fields must stay uint32_t");
static_assert(sizeof(AutorepeatFilterTiming) == 3 * sizeof(uint32_t),
"AutorepeatFilterTiming is read from flash with word loads");
/// Non-template base for AutorepeatFilter — all methods in filter.cpp.
/// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once.
/// Timings live in a PROGMEM table emitted by codegen, ended by an entry whose delay is
/// SCHEDULER_DONT_RUN (a step that could never advance anyway). Aligned loads are ESP8266 safe.
/// The two scheduled timers are keyed off `this` and `&active_timing_`; since the address
/// of `active_timing_` is taken as a scheduler key, the class must not be copied or moved.
class AutorepeatFilterBase : public Filter {
class AutorepeatFilter : public Filter {
public:
explicit AutorepeatFilter(const AutorepeatFilterTiming *timings) : timings_(timings) {}
AutorepeatFilter(const AutorepeatFilter &) = delete;
AutorepeatFilter &operator=(const AutorepeatFilter &) = delete;
optional<bool> new_value(bool value) override;
AutorepeatFilterBase(const AutorepeatFilterBase &) = delete;
AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete;
protected:
AutorepeatFilterBase() = default;
void next_timing_();
void next_value_(bool val);
const AutorepeatFilterTiming *timings_{nullptr};
uint8_t timings_count_{0};
uint8_t active_timing_{0};
};
/// Template wrapper that provides inline std::array storage for timings.
/// N is set by code generation to match the exact number of timings configured in YAML.
template<size_t N> class AutorepeatFilter : public AutorepeatFilterBase {
public:
explicit AutorepeatFilter(std::initializer_list<AutorepeatFilterTiming> timings) {
init_array_from(this->timings_storage_, timings);
this->timings_ = this->timings_storage_.data();
this->timings_count_ = N;
}
protected:
std::array<AutorepeatFilterTiming, N> timings_storage_{};
uint8_t active_timing_{0}; // before timings_ so it fills the padding after the Filter base
const AutorepeatFilterTiming *timings_;
};
class LambdaFilter : public Filter {
+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):
+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
+42 -8
View File
@@ -49,6 +49,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 +85,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 +101,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 +117,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 +364,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 +479,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{
@@ -658,6 +689,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_;
+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()
+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):
@@ -0,0 +1,26 @@
"""Tests for the binary sensor autorepeat filter codegen."""
from collections.abc import Callable
import re
def test_autorepeat_timings_shared_progmem_table(
generate_main: Callable[[str], str],
) -> None:
"""Identical timings share one PROGMEM table; an empty autorepeat gets the defaults."""
main_cpp = generate_main("tests/component_tests/binary_sensor/test_autorepeat.yaml")
tables = re.findall(
r"static constexpr binary_sensor::AutorepeatFilterTiming (\w+)\[\] PROGMEM",
main_cpp,
)
assert len(tables) == 2
shared, default = tables
assert main_cpp.count(f"AutorepeatFilter({shared});") == 2
assert f"AutorepeatFilter({default});" in main_cpp
assert "binary_sensor_autorepeat_timings" not in tables
default_table = main_cpp.split(f"{default}[] PROGMEM = ", 1)[1].split(";", 1)[0]
for field in (".delay = 1000", ".time_off = 100", ".time_on = 900"):
assert field in default_table
# Each table ends with a never-run delay instead of the filter storing a count.
assert main_cpp.count(".delay = SCHEDULER_DONT_RUN") == len(tables)
@@ -0,0 +1,35 @@
---
esphome:
name: test
esp32:
board: esp32dev
binary_sensor:
- platform: template
id: repeat_a
filters:
- autorepeat:
- delay: 2s
time_off: 200ms
time_on: 800ms
- delay: 5s
time_off: 100ms
time_on: 400ms
- platform: template
id: repeat_b
filters:
- autorepeat:
- delay: 2s
time_off: 200ms
time_on: 800ms
- delay: 5s
time_off: 100ms
time_on: 400ms
- platform: template
id: repeat_default
filters:
- autorepeat:
# A user id equal to the table's preferred name must not break codegen.
- platform: template
id: binary_sensor_autorepeat_timings
@@ -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{}); }
+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: