mirror of
https://github.com/esphome/esphome.git
synced 2026-10-19 12:42:50 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d226de405 | ||
|
|
ac74796263 | ||
|
|
c56bb14037 | ||
|
|
e8eba14c58 | ||
|
|
d0b0cd6e13 | ||
|
|
18e93a96fc | ||
|
|
01f1139476 | ||
|
|
053cb337aa | ||
|
|
3ac5918ec8 | ||
|
|
bf5d095cf6 | ||
|
|
eae1b99232 | ||
|
|
15936f9a55 | ||
|
|
aac085c5c7 | ||
|
|
fe66ac546a | ||
|
|
a8e5fd0d2f | ||
|
|
93afb0d092 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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{}); }
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user