mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 22:56:19 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d34efecf2 | ||
|
|
c4096d44d8 | ||
|
|
68f3a6b9a5 | ||
|
|
662bf7d7f0 | ||
|
|
0d71ab8efb | ||
|
|
4ce4768ebd | ||
|
|
fb13327922 | ||
|
|
4c62420f1b | ||
|
|
ca3f31643f | ||
|
|
d770004e0e | ||
|
|
b28efcd545 | ||
|
|
6b6d27f905 | ||
|
|
603c3539a3 | ||
|
|
f248a85b51 | ||
|
|
5a3d7e3292 | ||
|
|
c6d329db64 | ||
|
|
bdb203d742 | ||
|
|
f509516d60 | ||
|
|
4c856949c2 | ||
|
|
c90a5f4cff | ||
|
|
964fc1ef3f | ||
|
|
98f138f6b2 | ||
|
|
9179fe26ce | ||
|
|
e6c49e2bd9 | ||
|
|
d84293931b | ||
|
|
2c32ac2221 | ||
|
|
dde6906f98 | ||
|
|
4efd308345 | ||
|
|
f741c274d5 | ||
|
|
cf31c08a5c | ||
|
|
e7574a574b | ||
|
|
33484108a9 | ||
|
|
e697a40fda | ||
|
|
d1f065671e | ||
|
|
02da5c6484 | ||
|
|
b2440cb655 | ||
|
|
160d8b8f0c | ||
|
|
8899713ef9 | ||
|
|
f3cdefce21 | ||
|
|
b115813fbe | ||
|
|
ab45ab316a | ||
|
|
5b3a6c05bf | ||
|
|
cd53681787 |
@@ -412,15 +412,15 @@ void APIConnection::finalize_iterator_sync_() {
|
||||
}
|
||||
|
||||
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
|
||||
size_t initial_size = this->deferred_batch_.size();
|
||||
size_t max_batch = MAX_INITIAL_PER_BATCH;
|
||||
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
|
||||
iterator.advance();
|
||||
}
|
||||
// Budget by remaining batch capacity so a pass cannot overfill the batch;
|
||||
// stops early on a refused send and resumes next loop pass
|
||||
size_t batch_size = this->deferred_batch_.size();
|
||||
if (batch_size < MAX_INITIAL_BATCH_SIZE)
|
||||
iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size);
|
||||
|
||||
// If the batch is full, process it immediately
|
||||
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
|
||||
if (this->deferred_batch_.size() >= max_batch) {
|
||||
// Flush immediately once enough is queued (not guaranteed every pass);
|
||||
// partial batches go out via the batch timer or finalize_iterator_sync_()
|
||||
if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) {
|
||||
this->process_batch_();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +53,11 @@ void log_dropped_message(const char *tag, int line, const LogString *what);
|
||||
|
||||
// Keepalive timeout in milliseconds
|
||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
static constexpr size_t MAX_INITIAL_PER_BATCH = 34;
|
||||
// Deferred batch size cap during initial state/info sync
|
||||
static constexpr size_t MAX_INITIAL_BATCH_SIZE = 34;
|
||||
// Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch
|
||||
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
|
||||
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
|
||||
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_BATCH_SIZE,
|
||||
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE");
|
||||
|
||||
#ifdef USE_BENCHMARK
|
||||
class APIConnection;
|
||||
|
||||
@@ -36,7 +36,7 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and oth
|
||||
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
|
||||
|
||||
// Maximum number of messages to batch in a single write operation
|
||||
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
|
||||
// Must be >= MAX_INITIAL_BATCH_SIZE in api_connection.h (enforced by static_assert there)
|
||||
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
|
||||
|
||||
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
|
||||
|
||||
@@ -95,9 +95,17 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done(
|
||||
ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {}
|
||||
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
// Yield after every Nth service; bounds direct (non-batched) writes per loop pass
|
||||
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
|
||||
|
||||
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
|
||||
auto resp = service->encode_list_service_response();
|
||||
return this->client_->send_message(resp);
|
||||
if (!this->client_->send_message(resp))
|
||||
return false;
|
||||
// at_ is this service's index
|
||||
if ((this->at_ + 1) % SERVICE_YIELD_INTERVAL == 0)
|
||||
this->yield_after_step_();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
from esphome.components.const import CONF_ON_STATE_CHANGE
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DELAY,
|
||||
@@ -560,6 +561,11 @@ _CALLBACK_AUTOMATIONS = (
|
||||
async def _build_binary_sensor_automations(var, config):
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
|
||||
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
|
||||
if config.get(CONF_ON_MULTI_CLICK):
|
||||
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
|
||||
|
||||
for conf in config.get(CONF_ON_CLICK, []):
|
||||
trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
|
||||
@@ -673,3 +679,15 @@ async def to_code(config):
|
||||
async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
return cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
|
||||
# automation.cpp only implements the click/double_click/multi_click triggers
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{
|
||||
"automation.cpp": (
|
||||
"USE_BINARY_SENSOR_CLICK_TRIGGER",
|
||||
"USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER",
|
||||
),
|
||||
"filter.cpp": "USE_BINARY_SENSOR_FILTER",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER)
|
||||
|
||||
#include "automation.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::binary_sensor {
|
||||
|
||||
#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
|
||||
static const char *const TAG = "binary_sensor.automation";
|
||||
|
||||
// MultiClickTrigger timeout IDs.
|
||||
@@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() {
|
||||
this->trigger();
|
||||
}
|
||||
|
||||
#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
|
||||
#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER
|
||||
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
|
||||
if (max_length == 0) {
|
||||
return length >= min_length;
|
||||
@@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
|
||||
return length >= min_length && length <= max_length;
|
||||
}
|
||||
}
|
||||
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
|
||||
|
||||
} // namespace esphome::binary_sensor
|
||||
|
||||
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
|
||||
@@ -206,32 +206,36 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
||||
interval = config[CONF_INTERVAL]
|
||||
window = config[CONF_WINDOW]
|
||||
|
||||
if window > interval:
|
||||
raise cv.Invalid(
|
||||
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
|
||||
)
|
||||
# Labels are reused in every error below; the optional one names its key.
|
||||
windows = [("Scan window", window)]
|
||||
if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
|
||||
windows.append((CONF_CONNECTION_SCAN_WINDOW, connection_window))
|
||||
|
||||
for name, value in windows:
|
||||
if value > interval:
|
||||
raise cv.Invalid(
|
||||
f"{name} ({value}) needs to be smaller than scan interval ({interval})"
|
||||
)
|
||||
|
||||
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
|
||||
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
|
||||
# values here instead of letting the unit conversion silently overflow.
|
||||
for name, value in (("interval", interval), ("window", window)):
|
||||
for name, value in (("Scan interval", interval), *windows):
|
||||
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
|
||||
raise cv.Invalid(
|
||||
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
|
||||
)
|
||||
raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms")
|
||||
|
||||
# Validate what actually reaches the controller: both values are truncated to
|
||||
# whole 0.625 ms units, so a window/interval pair that differs by less than one
|
||||
# unit collapses to the same value — silently programming a 100 % duty cycle
|
||||
# (radio permanently on) from a config that asked for less.
|
||||
interval_units = to_ble_units(interval)
|
||||
window_units = to_ble_units(window)
|
||||
if window_units == interval_units and window < interval:
|
||||
raise cv.Invalid(
|
||||
f"Scan window ({window}) and interval ({interval}) both truncate to "
|
||||
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
|
||||
f"cycle. Separate them by at least 0.625 ms."
|
||||
)
|
||||
for name, value in windows:
|
||||
if to_ble_units(value) == interval_units and value < interval:
|
||||
raise cv.Invalid(
|
||||
f"{name} ({value}) and interval ({interval}) both truncate to "
|
||||
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
|
||||
f"cycle. Separate them by at least 0.625 ms."
|
||||
)
|
||||
|
||||
if interval.total_microseconds * 3 > duration.total_microseconds:
|
||||
raise cv.Invalid(
|
||||
@@ -247,11 +251,14 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
||||
# their own; also the fallback for esp32's conditional default.
|
||||
DEFAULT_SCAN_WINDOW = "30ms"
|
||||
|
||||
CONF_CONNECTION_SCAN_WINDOW = "connection_scan_window"
|
||||
|
||||
|
||||
def scan_parameters_schema(
|
||||
interval_default: str,
|
||||
*,
|
||||
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
|
||||
connection_window: bool = False,
|
||||
) -> cv.All:
|
||||
"""Build the scan_parameters value schema shared by all BLE trackers.
|
||||
|
||||
@@ -263,7 +270,9 @@ def scan_parameters_schema(
|
||||
can adjust it once sibling keys are resolved). The `active` option
|
||||
(default on) is unconditional: active scanning is part of the tracker
|
||||
contract — every current proxy client assumes it, so a passive-only
|
||||
tracker must not share this schema.
|
||||
tracker must not share this schema. connection_window opts in to the
|
||||
`connection_scan_window` option for trackers that can fall back to a
|
||||
smaller window while a GATT connection is active.
|
||||
"""
|
||||
schema = {
|
||||
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
||||
@@ -272,6 +281,8 @@ def scan_parameters_schema(
|
||||
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||||
}
|
||||
if connection_window:
|
||||
schema[cv.Optional(CONF_CONNECTION_SCAN_WINDOW)] = cv.positive_time_period
|
||||
return cv.All(cv.Schema(schema), validate_scan_parameters)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
from esphome import yaml_util
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADVANCED,
|
||||
@@ -3451,3 +3452,10 @@ def process_stacktrace(config, line, backtrace_state):
|
||||
_decode_pc(config, addr.group())
|
||||
|
||||
return backtrace_state
|
||||
|
||||
|
||||
# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which
|
||||
# are instantiated solely by the pin schema codegen (esp32_pin_to_code)
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"}
|
||||
)
|
||||
|
||||
@@ -124,6 +124,15 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
|
||||
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
|
||||
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
|
||||
static constexpr uint32_t CRASH_DATA_VERSION = 4;
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's
|
||||
// cause/vaddr slots were never written (not a real exception frame).
|
||||
static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM;
|
||||
#elif CONFIG_IDF_TARGET_ARCH_RISCV
|
||||
// Synchronous mcause exception codes are small and have no interrupt bit;
|
||||
// anything else in a non-pseudo record is a stale slot.
|
||||
static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32;
|
||||
#endif
|
||||
struct RawCrashData {
|
||||
uint32_t version;
|
||||
uint32_t magic;
|
||||
@@ -198,10 +207,28 @@ void crash_handler_clear() {
|
||||
s_raw_crash_data.magic = 0;
|
||||
}
|
||||
|
||||
// Whether the cause slot was written by a real exception frame.
|
||||
static bool cause_slot_was_written() {
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT;
|
||||
#else
|
||||
return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Look up the exception cause as a human-readable string.
|
||||
// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays
|
||||
// not exposed via any public API.
|
||||
static const char *get_exception_reason() {
|
||||
uint8_t exception = s_raw_crash_data.exception;
|
||||
if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) {
|
||||
// Abort-class panics carry no cause register
|
||||
return nullptr;
|
||||
}
|
||||
if (!cause_slot_was_written()) {
|
||||
// Garbage from old-build or corrupt records; report just the type
|
||||
return nullptr;
|
||||
}
|
||||
#if CONFIG_IDF_TARGET_ARCH_XTENSA
|
||||
if (s_raw_crash_data.pseudo_excause) {
|
||||
// SoC-level panic: watchdog, cache error, etc.
|
||||
@@ -354,10 +381,11 @@ static const char *const FAULT_ADDR_REG = "MTVAL";
|
||||
static const char *const FAULT_ADDR_REG_LOWER = "mtval";
|
||||
#endif
|
||||
|
||||
// Whether the fault address is meaningful — real CPU faults only, not
|
||||
// aborts/watchdogs or SoC-level pseudo exceptions.
|
||||
// Whether the fault address is meaningful: real CPU faults with a validly
|
||||
// written frame only.
|
||||
static bool has_fault_addr() {
|
||||
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
|
||||
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause &&
|
||||
cause_slot_was_written();
|
||||
}
|
||||
|
||||
// The record was captured by a different firmware build (it survives soft
|
||||
@@ -458,6 +486,10 @@ void crash_handler_log() {
|
||||
// into NOINIT memory before the normal panic handler runs.
|
||||
//
|
||||
extern "C" {
|
||||
// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an
|
||||
// abort; weak so builds without the task watchdog still link.
|
||||
extern bool g_twdt_isr __attribute__((weak));
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
// Names are mandated by the --wrap linker mechanism
|
||||
extern void __real_esp_panic_handler(panic_info_t *info);
|
||||
@@ -470,6 +502,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
s_raw_crash_data.exception = (uint8_t) info->exception;
|
||||
s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0;
|
||||
s_raw_crash_data.crashed_core = (uint8_t) info->core;
|
||||
if (g_panic_abort) {
|
||||
// IDF reclassifies to ABORT only inside esp_panic_handler(), after this
|
||||
// wrapper captured info->exception; correct it here. TWDT is our own
|
||||
// distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is
|
||||
// not stored; the symbolized backtrace already identifies the site.
|
||||
bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr;
|
||||
s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT);
|
||||
}
|
||||
// Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot
|
||||
s_raw_crash_data.cause = 0;
|
||||
s_raw_crash_data.fault_addr = 0;
|
||||
@@ -487,8 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
// Xtensa: walk the backtrace using the public API
|
||||
if (info->frame != nullptr) {
|
||||
auto *xt_frame = (XtExcFrame *) info->frame;
|
||||
s_raw_crash_data.cause = xt_frame->exccause;
|
||||
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
|
||||
if (!g_panic_abort) {
|
||||
// Abort-class frames carry no useful cause/vaddr: TWDT task snapshots
|
||||
// never wrote them and abort() traps describe only the synthetic trap.
|
||||
s_raw_crash_data.cause = xt_frame->exccause;
|
||||
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
|
||||
}
|
||||
s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE);
|
||||
}
|
||||
|
||||
@@ -510,8 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
|
||||
// RISC-V: capture MEPC + RA, then scan stack for code addresses
|
||||
if (info->frame != nullptr) {
|
||||
auto *rv_frame = (RvExcFrame *) info->frame;
|
||||
s_raw_crash_data.cause = rv_frame->mcause;
|
||||
s_raw_crash_data.fault_addr = rv_frame->mtval;
|
||||
if (!g_panic_abort) {
|
||||
// See the Xtensa branch: abort-class frames carry no valid cause/vaddr.
|
||||
s_raw_crash_data.cause = rv_frame->mcause;
|
||||
s_raw_crash_data.fault_addr = rv_frame->mtval;
|
||||
}
|
||||
s_raw_crash_data.backtrace_count =
|
||||
capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
// Also defines the core ISRInternalGPIOPin methods; those are only reachable
|
||||
// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely.
|
||||
#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO)
|
||||
|
||||
#include "gpio.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO
|
||||
|
||||
@@ -257,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All(
|
||||
|
||||
@pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA)
|
||||
async def esp32_pin_to_code(config):
|
||||
cg.add_define("USE_ESP32_INTERNAL_GPIO")
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
num = config[CONF_NUMBER]
|
||||
cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}")))
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base, esp32_ble, ota
|
||||
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
@@ -73,8 +74,9 @@ def _get_required_features() -> set[BLEFeatures]:
|
||||
|
||||
# Slot counters sizing the tracker's StaticVector storage; one request per
|
||||
# registered listener or client.
|
||||
CLIENT_COUNT_DEFINE = "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT"
|
||||
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
|
||||
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
|
||||
_request_client_slot = cg.slot_counter(CLIENT_COUNT_DEFINE)
|
||||
|
||||
|
||||
def register_ble_features(features: set[BLEFeatures]) -> None:
|
||||
@@ -147,6 +149,7 @@ class TrackerData:
|
||||
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
|
||||
|
||||
scan_window_defaulted: bool = False
|
||||
connection_window_injected: bool = False
|
||||
|
||||
|
||||
def _get_data() -> TrackerData:
|
||||
@@ -175,17 +178,34 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
|
||||
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
|
||||
scan would starve wifi outright, and a user-set window is never touched.
|
||||
Raising to the interval cannot invalidate the already-validated
|
||||
parameters, so no re-validation is needed.
|
||||
parameters, so no re-validation is needed. The connection window is
|
||||
checked against the window here, after the raise.
|
||||
"""
|
||||
params = config[CONF_SCAN_PARAMETERS]
|
||||
if (
|
||||
_get_data().scan_window_defaulted
|
||||
and config.get(CONF_SOFTWARE_COEXISTENCE)
|
||||
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
|
||||
):
|
||||
params = config[CONF_SCAN_PARAMETERS]
|
||||
# Copy so the config dump shows a plain value instead of a YAML
|
||||
# anchor/alias pair pointing at the interval.
|
||||
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
|
||||
# Arm the connection-time fallback unless the user set one. Injected
|
||||
# after validation; safe because it equals the validated window default.
|
||||
if CONF_CONNECTION_SCAN_WINDOW not in params:
|
||||
params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period(
|
||||
ble_device_base.DEFAULT_SCAN_WINDOW
|
||||
)
|
||||
_get_data().connection_window_injected = True
|
||||
if (
|
||||
connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)
|
||||
) is not None and connection_window > params[CONF_WINDOW]:
|
||||
# A larger value would widen the scan during connections.
|
||||
raise cv.Invalid(
|
||||
f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be "
|
||||
f"smaller than the scan window ({params[CONF_WINDOW]})",
|
||||
path=[CONF_SCAN_PARAMETERS, CONF_CONNECTION_SCAN_WINDOW],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -194,7 +214,7 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
|
||||
# window/interval pairs that collapse to the same 0.625 ms unit count.
|
||||
# The window default is conditional (see _scan_window_default above).
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
|
||||
"320ms", window_default=_scan_window_default
|
||||
"320ms", window_default=_scan_window_default, connection_window=True
|
||||
)
|
||||
|
||||
# Codegen helpers are owned by ble_device_base; kept under the historical names
|
||||
@@ -288,6 +308,25 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_scan_duration(params[CONF_DURATION]))
|
||||
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL])))
|
||||
cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW])))
|
||||
if (connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
|
||||
# Emitted at FINAL so a scan-only build, where the guarded C++ path
|
||||
# compiles out, skips the call entirely.
|
||||
window_units = ble_device_base.to_ble_units(connection_window)
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _emit_connection_scan_window() -> None:
|
||||
if cg.get_slot_count(CLIENT_COUNT_DEFINE):
|
||||
cg.add(var.set_connection_scan_window(window_units))
|
||||
elif not _get_data().connection_window_injected:
|
||||
# Warn only for a user-set value; the injected default drops silently.
|
||||
_LOGGER.warning(
|
||||
"'%s' has no effect because this build has no BLE client "
|
||||
"components (for example bluetooth_proxy with active "
|
||||
"connections, or ble_client)",
|
||||
CONF_CONNECTION_SCAN_WINDOW,
|
||||
)
|
||||
|
||||
CORE.add_job(_emit_connection_scan_window)
|
||||
cg.add(var.set_scan_active(params[CONF_ACTIVE]))
|
||||
cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS]))
|
||||
|
||||
|
||||
@@ -122,6 +122,9 @@ void ESP32BLETracker::loop() {
|
||||
// - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_()
|
||||
// - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or
|
||||
// connecting client finishes (state change), or scanner reaches RUNNING/IDLE
|
||||
// - connection-window restart: scan_params_ is only written in start_scan_()
|
||||
// (which changes scanner state via set_scanner_state_()), and
|
||||
// counts.active/disconnecting only change on client state changes
|
||||
//
|
||||
// All conditions that affect the logic below are tied to state changes that increment
|
||||
// state_version_, so the fast path is safe.
|
||||
@@ -144,6 +147,19 @@ void ESP32BLETracker::loop() {
|
||||
(this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) {
|
||||
this->handle_scanner_failure_();
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
// The programmed window no longer matches the connection state (typically
|
||||
// the last connection dropped): restart so the right window applies now
|
||||
// instead of at the end of the scan period. Continuous only (a user-started
|
||||
// scan would not restart); !disconnecting matches the restart gate below.
|
||||
if (this->scanner_state_ == ScannerState::RUNNING && this->scan_continuous_ && !counts.disconnecting &&
|
||||
this->scan_params_.scan_window != this->desired_scan_window_(counts.active)) {
|
||||
// Same logical scan period continues: no on_scan_end sweeps for this
|
||||
// restart. Only armed when the stop was issued.
|
||||
this->skip_next_scan_end_ = this->stop_scan_();
|
||||
}
|
||||
#endif
|
||||
/*
|
||||
|
||||
Avoid starting the scanner if:
|
||||
@@ -195,19 +211,23 @@ void ESP32BLETracker::stop_scan() {
|
||||
// reason at D themselves, and the user-facing stop action is deliberate.
|
||||
ESP_LOGV(TAG, "Stopping scan.");
|
||||
this->scan_continuous_ = false;
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
// The window-change restart is abandoned with continuous scanning.
|
||||
this->skip_next_scan_end_ = false;
|
||||
#endif
|
||||
this->stop_scan_();
|
||||
}
|
||||
|
||||
void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); }
|
||||
|
||||
void ESP32BLETracker::stop_scan_() {
|
||||
bool ESP32BLETracker::stop_scan_() {
|
||||
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
|
||||
// IDLE means there is nothing to stop; STOPPING means a stop is already in
|
||||
// flight and will finish on its own. Neither is an error.
|
||||
if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) {
|
||||
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// Reset timeout state machine when stopping scan
|
||||
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
|
||||
@@ -215,8 +235,9 @@ void ESP32BLETracker::stop_scan_() {
|
||||
esp_err_t err = esp_ble_gap_stop_scanning();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ble_gap_stop_scanning failed: %d", err);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ESP32BLETracker::start_scan_(bool first) {
|
||||
@@ -230,16 +251,11 @@ void ESP32BLETracker::start_scan_(bool first) {
|
||||
}
|
||||
this->set_scanner_state_(ScannerState::STARTING);
|
||||
ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING.");
|
||||
if (!first) {
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
if (!first)
|
||||
this->notify_scan_end_();
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
this->skip_next_scan_end_ = false;
|
||||
#endif
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->neutral_listeners_)
|
||||
listener->on_scan_end();
|
||||
#endif
|
||||
}
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
this->discovered_log_.clear();
|
||||
#endif
|
||||
@@ -247,7 +263,17 @@ void ESP32BLETracker::start_scan_(bool first) {
|
||||
this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC;
|
||||
this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL;
|
||||
this->scan_params_.scan_interval = this->scan_interval_;
|
||||
this->scan_params_.scan_window = this->scan_window_;
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
// Count fresh: an automation can start a scan before loop() refreshes the counts.
|
||||
const uint32_t window = this->desired_scan_window_(this->count_client_states_().active);
|
||||
if (window != this->scan_window_) {
|
||||
// Guarantee the connection airtime instead of scanning wall to wall.
|
||||
ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", window);
|
||||
}
|
||||
#else
|
||||
const uint32_t window = this->scan_window_;
|
||||
#endif
|
||||
this->scan_params_.scan_window = window;
|
||||
|
||||
// Start timeout monitoring in loop() instead of using scheduler
|
||||
// This prevents false reboots when the loop is blocked
|
||||
@@ -408,6 +434,11 @@ void ESP32BLETracker::dump_config() {
|
||||
" Continuous Scanning: %s",
|
||||
this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f,
|
||||
this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
if (this->connection_scan_window_ != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f);
|
||||
}
|
||||
#endif
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Scanner State: %s\n"
|
||||
" Connecting: %d, discovered: %d, disconnecting: %d, active: %d",
|
||||
@@ -487,6 +518,18 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
|
||||
// Reset timeout state machine instead of cancelling scheduler timeout
|
||||
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
|
||||
|
||||
this->notify_scan_end_();
|
||||
|
||||
this->set_scanner_state_(ScannerState::IDLE);
|
||||
}
|
||||
|
||||
void ESP32BLETracker::notify_scan_end_() {
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
// Window-change restart continues the same scan period; the flag stays set
|
||||
// across the stop and is cleared by the restart in start_scan_.
|
||||
if (this->skip_next_scan_end_)
|
||||
return;
|
||||
#endif
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
@@ -495,8 +538,6 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
|
||||
for (auto *listener : this->neutral_listeners_)
|
||||
listener->on_scan_end();
|
||||
#endif
|
||||
|
||||
this->set_scanner_state_(ScannerState::IDLE);
|
||||
}
|
||||
|
||||
void ESP32BLETracker::handle_scanner_failure_() {
|
||||
@@ -534,6 +575,8 @@ void ESP32BLETracker::try_promote_discovered_clients_() {
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Promoting client to connect");
|
||||
// A connect ends the scan period a window-change restart was continuing.
|
||||
this->skip_next_scan_end_ = false;
|
||||
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
|
||||
this->update_coex_preference_(true);
|
||||
#endif
|
||||
|
||||
@@ -169,6 +169,9 @@ class ESP32BLETracker final : public Component,
|
||||
void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; }
|
||||
void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; }
|
||||
void set_scan_window(uint32_t scan_window) { scan_window_ = scan_window; }
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; }
|
||||
#endif
|
||||
void set_scan_active(bool scan_active) { scan_active_ = scan_active; }
|
||||
bool get_scan_active() const { return scan_active_; }
|
||||
void set_scan_continuous(bool scan_continuous) { scan_continuous_ = scan_continuous; }
|
||||
@@ -226,7 +229,10 @@ class ESP32BLETracker final : public Component,
|
||||
ScannerState get_scanner_state() const { return this->scanner_state_; }
|
||||
|
||||
protected:
|
||||
void stop_scan_();
|
||||
/// Returns true when a stop was issued to the controller.
|
||||
bool stop_scan_();
|
||||
/// Fire on_scan_end on every listener unless a window-change restart suppressed it.
|
||||
void notify_scan_end_();
|
||||
/// Start a single scan by setting up the parameters and doing some esp-idf calls.
|
||||
void start_scan_(bool first);
|
||||
/// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received.
|
||||
@@ -313,6 +319,15 @@ class ESP32BLETracker final : public Component,
|
||||
uint32_t scan_duration_;
|
||||
uint32_t scan_interval_;
|
||||
uint32_t scan_window_;
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
/// Window used while a GATT connection is active; set by the user, or
|
||||
/// defaulted when the window was raised to full duty (0 = no fallback).
|
||||
uint32_t connection_scan_window_{0};
|
||||
/// The window to scan at for the given number of active GATT connections.
|
||||
uint32_t desired_scan_window_(uint8_t active) const {
|
||||
return (this->connection_scan_window_ != 0 && active > 0) ? this->connection_scan_window_ : this->scan_window_;
|
||||
}
|
||||
#endif
|
||||
esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS};
|
||||
esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS};
|
||||
|
||||
@@ -330,15 +345,20 @@ class ESP32BLETracker final : public Component,
|
||||
/// state_version_ to detect if any state changed since last iteration.
|
||||
uint8_t last_processed_version_{0};
|
||||
ScannerState scanner_state_{ScannerState::IDLE};
|
||||
bool scan_continuous_;
|
||||
bool scan_active_;
|
||||
// Packed 1-bit flags.
|
||||
bool scan_continuous_ : 1;
|
||||
bool scan_active_ : 1;
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
bool scan_continuous_before_ota_{false};
|
||||
bool scan_continuous_before_ota_ : 1 {false};
|
||||
#endif
|
||||
bool ble_was_disabled_ : 1 {true};
|
||||
bool parse_advertisements_ : 1 {false};
|
||||
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
|
||||
/// Suppress the window-change restart's on_scan_end sweeps (stop and start).
|
||||
bool skip_next_scan_end_ : 1 {false};
|
||||
#endif
|
||||
bool ble_was_disabled_{true};
|
||||
bool parse_advertisements_{false};
|
||||
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
|
||||
bool coex_prefer_ble_{false};
|
||||
bool coex_prefer_ble_ : 1 {false};
|
||||
#endif
|
||||
// Scan timeout state machine
|
||||
enum class ScanTimeoutState : uint8_t {
|
||||
@@ -346,10 +366,10 @@ class ESP32BLETracker final : public Component,
|
||||
MONITORING, // Actively monitoring for timeout
|
||||
EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot
|
||||
};
|
||||
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
|
||||
uint32_t scan_start_time_{0};
|
||||
/// Precomputed timeout value: scan_duration_ * 2000
|
||||
uint32_t scan_timeout_ms_{0};
|
||||
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
|
||||
};
|
||||
|
||||
// NOLINTNEXTLINE
|
||||
|
||||
@@ -398,7 +398,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
|
||||
#endif
|
||||
|
||||
// begin() may block for a few seconds while it locks flash.
|
||||
// begin() returns quickly; flash sectors are erased incrementally during write().
|
||||
error_code = this->backend_->begin(ota_size, ota_type);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
|
||||
@@ -159,9 +159,6 @@ class EthernetComponent final : public Component {
|
||||
const char *get_use_address() const { return this->use_address_; }
|
||||
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
|
||||
void get_eth_mac_address_raw(uint8_t *mac);
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
std::string get_eth_mac_address_pretty();
|
||||
const char *get_eth_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
|
||||
eth_duplex_t get_duplex_mode();
|
||||
eth_speed_t get_link_speed();
|
||||
|
||||
@@ -928,11 +928,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
|
||||
}
|
||||
|
||||
std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
|
||||
}
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
|
||||
@@ -249,11 +249,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string EthernetComponent::get_eth_mac_address_pretty() {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
|
||||
}
|
||||
|
||||
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
|
||||
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
|
||||
@@ -196,6 +196,9 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
|
||||
}
|
||||
|
||||
container->feed_wdt();
|
||||
// IDF is the only backend reusing the container across redirect hops;
|
||||
// drop the previous hop's headers (Arduino/host collect only the final response)
|
||||
container->response_headers_.clear();
|
||||
container->content_length = esp_http_client_fetch_headers(client);
|
||||
container->set_chunked(esp_http_client_is_chunked_response(client));
|
||||
container->feed_wdt();
|
||||
|
||||
@@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() {
|
||||
}
|
||||
}
|
||||
|
||||
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container) {
|
||||
if (this->update_started_) {
|
||||
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container,
|
||||
bool abort_backend) {
|
||||
if (abort_backend) {
|
||||
ESP_LOGV(TAG, "Aborting OTA backend");
|
||||
backend->abort();
|
||||
}
|
||||
@@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
auto error_code = backend->begin(container->content_length);
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "backend->begin error: %d", error_code);
|
||||
this->cleanup_(std::move(backend), container);
|
||||
// Nothing to abort: begin() failed, so no OTA handle was opened
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/false);
|
||||
return error_code;
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error);
|
||||
}
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return OTA_CONNECTION_ERROR;
|
||||
}
|
||||
|
||||
@@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
md5_receive.add(buf, bufsize_or_error);
|
||||
|
||||
// write bytes to OTA backend
|
||||
this->update_started_ = true;
|
||||
error_code = backend->write(buf, bufsize_or_error);
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
// error code explanation available at
|
||||
// https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h
|
||||
ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code,
|
||||
container->get_bytes_read() - bufsize_or_error, container->content_length);
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return error_code;
|
||||
}
|
||||
}
|
||||
@@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
this->md5_computed_ = md5_receive_str;
|
||||
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
|
||||
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
|
||||
} else {
|
||||
backend->set_update_md5(md5_receive_str);
|
||||
@@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
error_code = backend->end();
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code);
|
||||
this->cleanup_(std::move(backend), container);
|
||||
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
|
||||
return error_code;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
|
||||
void flash();
|
||||
|
||||
protected:
|
||||
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container);
|
||||
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
|
||||
uint8_t do_ota_();
|
||||
std::string get_url_with_auth_(const std::string &url);
|
||||
bool http_get_md5_();
|
||||
@@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
|
||||
std::string username_{};
|
||||
std::string url_{};
|
||||
int status_ = -1;
|
||||
bool update_started_ = false;
|
||||
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
template<typename... Ts>
|
||||
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
class SetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, temperature)
|
||||
|
||||
@@ -17,12 +17,12 @@ class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<Mitsubi
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
class ClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
|
||||
};
|
||||
|
||||
template<typename... Ts> class VaneControlAction : public Action<Ts...> {
|
||||
template<typename... Ts> class VaneControlAction final : public Action<Ts...> {
|
||||
public:
|
||||
using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t<Ts> &...);
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
|
||||
traits.add_supported_fan_mode(p.second);
|
||||
}
|
||||
|
||||
traits.set_supported_swing_modes(this->supported_swing_modes_);
|
||||
traits.set_supported_swing_modes(this->swing_mode_manager_.supported_swing_modes());
|
||||
|
||||
const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit();
|
||||
traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS);
|
||||
@@ -109,33 +109,11 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
|
||||
}
|
||||
|
||||
if (const auto swing_mode = call.get_swing_mode()) {
|
||||
auto vane = this->last_non_swing_vane_mode_;
|
||||
auto wide = this->last_non_swing_wide_vane_mode_;
|
||||
|
||||
switch (*swing_mode) {
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
vane = MitsubishiCN105::VaneMode::SWING;
|
||||
wide = MitsubishiCN105::WideVaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
vane = MitsubishiCN105::VaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
wide = MitsubishiCN105::WideVaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
default:
|
||||
break;
|
||||
if (const auto vane = this->swing_mode_manager_.vane_from(*swing_mode)) {
|
||||
this->parent_->set_vane_mode(*vane);
|
||||
}
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
this->parent_->set_vane_mode(vane);
|
||||
}
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
this->parent_->set_wide_vane_mode(wide);
|
||||
if (const auto wide = this->swing_mode_manager_.wide_vane_from(*swing_mode)) {
|
||||
this->parent_->set_wide_vane_mode(*wide);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,64 +144,39 @@ void MitsubishiCN105Climate::apply_values_() {
|
||||
ESP_LOGD(TAG, "Unable to map fan mode");
|
||||
}
|
||||
|
||||
if (!this->supported_swing_modes_.empty()) {
|
||||
bool vertical_swinging = false;
|
||||
bool horizontal_swinging = false;
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) {
|
||||
vertical_swinging = true;
|
||||
} else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
|
||||
this->last_non_swing_vane_mode_ = status.vane_mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
|
||||
horizontal_swinging = true;
|
||||
} else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
|
||||
this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (vertical_swinging && horizontal_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_BOTH;
|
||||
} else if (vertical_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
} else if (horizontal_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
|
||||
} else {
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
if (const auto swing_mode =
|
||||
this->swing_mode_manager_.update_and_get_swing_mode(status.vane_mode, status.wide_vane_mode)) {
|
||||
this->swing_mode = *swing_mode;
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
|
||||
this->supported_swing_modes_.clear();
|
||||
climate::ClimateSwingModeMask supported_swing_modes;
|
||||
switch (mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
supported_swing_modes.insert(climate::CLIMATE_SWING_BOTH);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this->swing_mode_manager_.set_supported_swing_modes(supported_swing_modes);
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#include "mitsubishi_cn105_swing_mode_manager.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented<MitsubishiCN105Component> {
|
||||
class MitsubishiCN105Climate final : public climate::Climate,
|
||||
public Component,
|
||||
public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
@@ -25,14 +28,12 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
|
||||
protected:
|
||||
void apply_values_();
|
||||
|
||||
climate::ClimateSwingModeMask supported_swing_modes_{};
|
||||
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
|
||||
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
|
||||
SwingModeManager swing_mode_manager_;
|
||||
};
|
||||
|
||||
// Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
template<typename... Ts>
|
||||
class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
class LegacySetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, temperature)
|
||||
|
||||
@@ -41,7 +42,7 @@ class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<M
|
||||
|
||||
// Legacy climate action compatibility. Remove in 2027.2.0.
|
||||
template<typename... Ts>
|
||||
class LegacyClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
class LegacyClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ struct VaneCall {
|
||||
MitsubishiCN105Component *parent_;
|
||||
};
|
||||
|
||||
class MitsubishiCN105Component : public Component, public uart::UARTDevice {
|
||||
class MitsubishiCN105Component final : public Component, public uart::UARTDevice {
|
||||
public:
|
||||
explicit MitsubishiCN105Component() : hp_(*this) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#include "mitsubishi_cn105.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
class SwingModeManager final {
|
||||
public:
|
||||
const climate::ClimateSwingModeMask &supported_swing_modes() const { return this->supported_swing_modes_; }
|
||||
void set_supported_swing_modes(const climate::ClimateSwingModeMask &supported_swing_modes) {
|
||||
this->supported_swing_modes_ = supported_swing_modes;
|
||||
}
|
||||
|
||||
std::optional<MitsubishiCN105::VaneMode> vane_from(climate::ClimateSwingMode swing_mode) const {
|
||||
if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
switch (swing_mode) {
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
return MitsubishiCN105::VaneMode::SWING;
|
||||
default:
|
||||
return this->last_non_swing_vane_mode_;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<MitsubishiCN105::WideVaneMode> wide_vane_from(climate::ClimateSwingMode swing_mode) const {
|
||||
if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
switch (swing_mode) {
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
return MitsubishiCN105::WideVaneMode::SWING;
|
||||
default:
|
||||
return this->last_non_swing_wide_vane_mode_;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<climate::ClimateSwingMode> update_and_get_swing_mode(MitsubishiCN105::VaneMode vane_mode,
|
||||
MitsubishiCN105::WideVaneMode wide_vane_mode) {
|
||||
if (this->supported_swing_modes_.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool vertical_swinging = false;
|
||||
bool horizontal_swinging = false;
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
if (vane_mode == MitsubishiCN105::VaneMode::SWING) {
|
||||
vertical_swinging = true;
|
||||
} else if (vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
|
||||
this->last_non_swing_vane_mode_ = vane_mode;
|
||||
}
|
||||
}
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
if (wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
|
||||
horizontal_swinging = true;
|
||||
} else if (wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
|
||||
this->last_non_swing_wide_vane_mode_ = wide_vane_mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (vertical_swinging && horizontal_swinging) {
|
||||
return climate::CLIMATE_SWING_BOTH;
|
||||
}
|
||||
if (vertical_swinging) {
|
||||
return climate::CLIMATE_SWING_VERTICAL;
|
||||
}
|
||||
if (horizontal_swinging) {
|
||||
return climate::CLIMATE_SWING_HORIZONTAL;
|
||||
}
|
||||
return climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
|
||||
private:
|
||||
climate::ClimateSwingModeMask supported_swing_modes_{};
|
||||
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
|
||||
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select,
|
||||
public Component,
|
||||
public Parented<MitsubishiCN105Component> {
|
||||
class MitsubishiCN105VerticalVaneDirectionSelect final : public select::Select,
|
||||
public Component,
|
||||
public Parented<MitsubishiCN105Component> {
|
||||
public:
|
||||
void setup() override;
|
||||
void publish_vane_state(MitsubishiCN105::VaneMode mode);
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_DISABLE_CRC,
|
||||
CONF_FLOW_CONTROL_PIN,
|
||||
CONF_ID,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_helpers import gpio_pin_expression
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time"
|
||||
|
||||
MODBUS_ROLES = ["client", "server"]
|
||||
|
||||
|
||||
class _CommandOption(NamedTuple):
|
||||
"""One per-command option forwarded to the hub (modbus::CommandOptions)."""
|
||||
|
||||
conf_key: str
|
||||
field: str # the C++ field, and so the set_<field>() setter name
|
||||
validator: Any # the static (non-templatable) validator for the key
|
||||
cpp_type: Any # the C++ type the value is generated as
|
||||
default: Any
|
||||
|
||||
|
||||
# Per-direction command options. Single-sourcing the schema and the setter generation here keeps
|
||||
# them from drifting; the C++ side must add the matching field per the rules documented on
|
||||
# CommandOptions (modbus.h).
|
||||
_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = {
|
||||
"read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)],
|
||||
"write": [],
|
||||
}
|
||||
|
||||
|
||||
def _command_options(direction: str) -> list[_CommandOption]:
|
||||
try:
|
||||
return _COMMAND_OPTIONS[direction]
|
||||
except KeyError:
|
||||
raise ValueError(f"unknown command-options direction {direction!r}") from None
|
||||
|
||||
|
||||
def command_options_schema(
|
||||
*, direction: Literal["read", "write"], templatable: bool = False
|
||||
) -> dict[cv.Optional, Any]:
|
||||
"""Schema fragment for the per-command options a component forwards to the hub
|
||||
(modbus::CommandOptions). Extend this into any schema that queues commands. Keys are
|
||||
direction-specific so a schema never offers an option the hub would strip (e.g.
|
||||
continuous on a write); the write side has no options yet. For actions (templatable=True the
|
||||
keys also accept lambdas), register the values with register_templatable_command_options().
|
||||
"""
|
||||
return {
|
||||
cv.Optional(option.conf_key, default=option.default): (
|
||||
cv.templatable(option.validator) if templatable else option.validator
|
||||
)
|
||||
for option in _command_options(direction)
|
||||
}
|
||||
|
||||
|
||||
async def register_templatable_command_options(
|
||||
var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str
|
||||
) -> None:
|
||||
"""Generate the set_<option>() calls for the given direction's command options present in config.
|
||||
Pass the same direction the action's command_options_schema() used, so the keys generated match
|
||||
the ones the schema offered - a write action never emits a read option's setter. Options the
|
||||
schema did not add are simply absent. The consumer's C++ class declares a matching
|
||||
TEMPLATABLE_VALUE per option (e.g. TEMPLATABLE_VALUE(bool, continuous)).
|
||||
"""
|
||||
for option in _command_options(direction):
|
||||
if option.conf_key not in config:
|
||||
continue
|
||||
value = config[option.conf_key]
|
||||
# Skip codegen when the value is its C++ zero (TemplatableFn::value() returns T{} when
|
||||
# unset): behaviourally identical, and saves a thunk plus a setup() call per action.
|
||||
if cg.is_template(value) or value != type(value)():
|
||||
cg.add(
|
||||
getattr(var, f"set_{option.field}")(
|
||||
await cg.templatable(value, args, option.cpp_type)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.typed_schema(
|
||||
{
|
||||
"client": cv.Schema(
|
||||
|
||||
@@ -146,7 +146,7 @@ bool ModbusClientHub::tx_buffer_empty() {
|
||||
// other states are mid-transaction or owed bookkeeping, not queued sends - and a READY continuous
|
||||
// poll does not count either, since it ranks below every one-shot, so a new send goes out first.
|
||||
for (const auto &cmd : this->tx_buffer_) {
|
||||
if (cmd.state == FrameState::READY && !cmd.continuous)
|
||||
if (cmd.state == FrameState::READY && !cmd.options.continuous)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -946,7 +946,7 @@ bool ModbusDeviceCommand::notify_retired() {
|
||||
bool ModbusDeviceCommand::response(std::span<const uint8_t> response_pdu) {
|
||||
this->state = this->state == FrameState::WAITING_RETIRED ? FrameState::RETIRED : FrameState::RECEIVED_RESPONSE;
|
||||
// A continuous poll is never consumed by its own response; a one-shot consumes one request here.
|
||||
if (!this->continuous)
|
||||
if (!this->options.continuous)
|
||||
this->decrement_pending();
|
||||
if (this->device == nullptr)
|
||||
return false;
|
||||
@@ -1070,15 +1070,12 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize the caller's options in place (the param is a by-value copy) so everything stored or
|
||||
// merged below carries effective options, never the raw request.
|
||||
// continuous is ignored for every mutating code (re-writing a value forever is never intended).
|
||||
const bool mutates = priority == CommandPriority::WRITE;
|
||||
bool continuous = false;
|
||||
if (options.continuous) {
|
||||
if (mutates) {
|
||||
ESP_LOGV(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
|
||||
} else {
|
||||
continuous = true;
|
||||
}
|
||||
if (options.continuous && priority == CommandPriority::WRITE) {
|
||||
ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address);
|
||||
options.continuous = false;
|
||||
}
|
||||
|
||||
// A duplicate of a live entry with the same owner is not queued twice; it resolves against that
|
||||
@@ -1104,10 +1101,10 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
}
|
||||
return false; // dropped: no entry, no callbacks - the refusal is the return value
|
||||
}
|
||||
if (continuous) {
|
||||
if (options.continuous) {
|
||||
item.make_continuous(true);
|
||||
ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", now polled continuously", address);
|
||||
} else if (item.continuous) {
|
||||
} else if (item.options.continuous) {
|
||||
// A one-shot duplicate downgrades the poll to a one-shot: it runs one more cycle to serve this
|
||||
// request, then stops (mirrors continuous incoming converting a one-shot the other way).
|
||||
item.make_continuous(false);
|
||||
@@ -1140,7 +1137,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span<const uint8_t> pdu, M
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address,
|
||||
format_hex_pretty_to(hex_buf, pdu.data(), pdu.size()));
|
||||
this->tx_buffer_.emplace_back(device, address, pdu, continuous, this->next_seq_++);
|
||||
this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,14 @@ enum class FrameState : uint8_t {
|
||||
};
|
||||
|
||||
// Per-command send options. Append-only; pass via designated initializers ({.continuous = true}).
|
||||
// The queue entry stores this struct whole, so a new field arrives at the queue with no plumbing -
|
||||
// but it arrives inert. Every new field must define three rules before it does anything:
|
||||
// 1. normalization in queue_pdu() (is it valid for this function code? e.g. continuous is
|
||||
// stripped for mutating codes),
|
||||
// 2. a merge rule for when a duplicate send absorbs into a live entry (continuous
|
||||
// upgrades/downgrades via make_continuous(); a new field needs its own answer),
|
||||
// 3. teardown: retire() resets the whole struct; silent_retire() leaves it, relying on the sweep
|
||||
// to erase the entry.
|
||||
struct CommandOptions {
|
||||
// A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes.
|
||||
bool continuous{false};
|
||||
@@ -126,26 +134,29 @@ struct CommandOptions {
|
||||
struct ModbusDeviceCommand {
|
||||
ModbusClientDevice *device;
|
||||
ModbusFrame frame;
|
||||
FrameState state{FrameState::READY};
|
||||
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
|
||||
bool continuous{false};
|
||||
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
|
||||
uint8_t pending{1};
|
||||
// Place-in-line stamp (hub's free-running counter); selection takes the oldest for round-robin
|
||||
// fairness within a class. Meant to wrap.
|
||||
// fairness within a class. Meant to wrap. Declared ahead of the byte fields so the tail packs
|
||||
// densely and a growing CommandOptions eats trailing padding before enlarging the struct.
|
||||
uint16_t seq{0};
|
||||
FrameState state{FrameState::READY};
|
||||
// Accepted requests this entry stands for, capped at max_pending(); drains one terminal each.
|
||||
// A continuous poll is a subscription: pending fixed at 1, removed only by cancellation or failure.
|
||||
uint8_t pending{1};
|
||||
// The entry's LIVE effective options, not a record of the caller's request: queue_pdu() normalizes
|
||||
// before storing, duplicate absorption mutates continuous via make_continuous(), and retire() resets
|
||||
// the struct (silent_retire() leaves it, relying on the sweep to erase the entry). See the
|
||||
// CommandOptions comment for the rules a new field must define.
|
||||
CommandOptions options;
|
||||
|
||||
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE); fully initialized here.
|
||||
// Build a command from a PDU span (caller bounds it to MAX_PDU_SIZE) and pre-normalized options;
|
||||
// fully initialized here.
|
||||
ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, std::span<const uint8_t> pdu,
|
||||
bool continuous = false, uint16_t seq = 0)
|
||||
: device(device),
|
||||
frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())),
|
||||
continuous(continuous),
|
||||
seq(seq) {}
|
||||
CommandOptions options = {}, uint16_t seq = 0)
|
||||
: device(device), frame(address, pdu.data(), static_cast<uint16_t>(pdu.size())), seq(seq), options(options) {}
|
||||
|
||||
// Transmit ordering class, derived (never stored): a continuous poll ranks below every one-shot.
|
||||
CommandPriority priority() const {
|
||||
return this->continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
|
||||
return this->options.continuous ? CommandPriority::CONTINUOUS : classify(this->frame.pdu()[0]);
|
||||
}
|
||||
// Wire-derived class: mutating codes rank WRITE; exception-flagged codes are excluded.
|
||||
static CommandPriority classify(uint8_t function_code) {
|
||||
@@ -161,7 +172,7 @@ struct ModbusDeviceCommand {
|
||||
uint8_t max_pending() const {
|
||||
const uint8_t fc = this->frame.pdu()[0];
|
||||
const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc);
|
||||
return (requeueable && !this->continuous) ? 2 : 1;
|
||||
return (requeueable && !this->options.continuous) ? 2 : 1;
|
||||
}
|
||||
// Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for
|
||||
// a response keeps its state as a reply-ignoring shell that resolves silently; any other goes RETIRED.
|
||||
@@ -196,11 +207,11 @@ struct ModbusDeviceCommand {
|
||||
// retroactively inflating that no-op.
|
||||
void make_continuous(bool continuous) {
|
||||
if (continuous) {
|
||||
this->continuous = true;
|
||||
this->options.continuous = true;
|
||||
this->pending = 1;
|
||||
} else {
|
||||
this->increment_pending();
|
||||
this->continuous = false;
|
||||
this->options.continuous = false;
|
||||
}
|
||||
}
|
||||
// Address-scoped clear: keep pending and device so the sweep delivers one on_not_sent() per un-run
|
||||
@@ -218,7 +229,7 @@ struct ModbusDeviceCommand {
|
||||
} else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED
|
||||
this->state = FrameState::RETIRED;
|
||||
}
|
||||
this->continuous = false;
|
||||
this->options = {}; // reset every option so a future field is torn down without editing here
|
||||
}
|
||||
|
||||
// True while the entry is still waiting for a response; the erase pass exempts these even at pending 0.
|
||||
@@ -253,6 +264,9 @@ struct ModbusDeviceCommand {
|
||||
bool notify_retired();
|
||||
|
||||
/// True if this command carries the same wire frame (address + PDU) as the given one.
|
||||
/// Cancellation matches the exact frame, not the action instance: a continuous poll whose
|
||||
/// start_address (or other field) is templated produces one poll per distinct frame, and a later
|
||||
/// cancel built from different argument values will not reach the polls it does not byte-match.
|
||||
bool same_frame(uint8_t address, std::span<const uint8_t> pdu) const {
|
||||
const auto own_pdu = this->frame.pdu();
|
||||
return own_pdu.size() == pdu.size() && this->frame.address() == address &&
|
||||
@@ -618,9 +632,6 @@ class ModbusClientDevice {
|
||||
inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); }
|
||||
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
|
||||
|
||||
// If more than one device is connected block sending a new command before a response is received
|
||||
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
bool waiting_for_response() { return !this->ready_for_immediate_send(); }
|
||||
bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); }
|
||||
|
||||
protected:
|
||||
|
||||
@@ -7,6 +7,7 @@ from esphome.components import modbus
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_COUNT,
|
||||
CONF_ID,
|
||||
CONF_ON_ERROR,
|
||||
@@ -156,16 +157,45 @@ _ACTION_BASE_SCHEMA = cv.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
MODBUS_CLIENT_SEND_SCHEMA = _ACTION_BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_PDU): cv.templatable(
|
||||
cv.All(
|
||||
cv.ensure_list(cv.hex_uint8_t),
|
||||
cv.Length(min=1, max=modbus.MAX_PDU_SIZE),
|
||||
)
|
||||
),
|
||||
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
|
||||
}
|
||||
# The write codes recognised by modbus::helpers::is_function_code_write() - keep in sync. 0x17
|
||||
# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half.
|
||||
_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17})
|
||||
|
||||
|
||||
def _no_continuous_on_write(config: ConfigType) -> ConfigType:
|
||||
"""Reject `continuous: true` on a static write PDU: continuous polling only applies to reads.
|
||||
Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at
|
||||
runtime, so a templated pdu or continuous falls through to that backstop."""
|
||||
pdu = config[CONF_PDU]
|
||||
if (
|
||||
isinstance(pdu, list)
|
||||
and config.get(CONF_CONTINUOUS) is True
|
||||
# Masking the exception bit (0x90 -> 0x10) makes this check stricter than the runtime hub,
|
||||
# whose classify() treats an exception-flagged code as a read and leaves continuous in place.
|
||||
and pdu[0] & 0x7F in _WRITE_FUNCTION_CODES
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code "
|
||||
f"0x{pdu[0]:02X}); continuous polling only applies to reads",
|
||||
path=[CONF_CONTINUOUS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
MODBUS_CLIENT_SEND_SCHEMA = cv.All(
|
||||
_ACTION_BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_PDU): cv.templatable(
|
||||
cv.All(
|
||||
cv.ensure_list(cv.hex_uint8_t),
|
||||
cv.Length(min=1, max=modbus.MAX_PDU_SIZE),
|
||||
)
|
||||
),
|
||||
**modbus.command_options_schema(direction="read", templatable=True),
|
||||
cv.Optional(CONF_ON_RESPONSE): _handler_schema(),
|
||||
}
|
||||
),
|
||||
_no_continuous_on_write,
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +204,7 @@ async def register_client_action(
|
||||
config: ConfigType,
|
||||
args: TemplateArgsType,
|
||||
response_args: TemplateArgsType,
|
||||
command_direction: str = "read",
|
||||
) -> cg.MockObj:
|
||||
"""Wire the shared action plumbing: hub parent, templated device address, outcome triggers.
|
||||
|
||||
@@ -235,6 +266,12 @@ async def register_client_action(
|
||||
await automation.build_automation(
|
||||
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
|
||||
)
|
||||
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
|
||||
# matching direction so a write action never generates a read option's setter; the write side
|
||||
# has no options yet, so this is a no-op there.
|
||||
await modbus.register_templatable_command_options(
|
||||
var, config, args, command_direction
|
||||
)
|
||||
return var
|
||||
|
||||
|
||||
@@ -318,6 +355,7 @@ def _read_schema(max_count: int) -> cv.All:
|
||||
cv.Optional(CONF_COUNT, default=1): cv.templatable(
|
||||
cv.int_range(min=1, max=max_count)
|
||||
),
|
||||
**modbus.command_options_schema(direction="read", templatable=True),
|
||||
}
|
||||
),
|
||||
_no_address_overflow(CONF_COUNT),
|
||||
@@ -379,7 +417,9 @@ async def read_input_registers_to_code(config, action_id, template_arg, args):
|
||||
async def _write_single_to_code(config, action_id, template_arg, args, value_type):
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
cg.add(var.set_value(await cg.templatable(config[CONF_VALUE], args, value_type)))
|
||||
return await register_client_action(var, config, args, [])
|
||||
return await register_client_action(
|
||||
var, config, args, [], command_direction="write"
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
@@ -458,7 +498,9 @@ async def write_multiple_registers_to_code(config, action_id, template_arg, args
|
||||
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16)
|
||||
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values))
|
||||
cg.add(var.set_values_static(arr, len(values)))
|
||||
return await register_client_action(var, config, args, [])
|
||||
return await register_client_action(
|
||||
var, config, args, [], command_direction="write"
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
@@ -482,7 +524,9 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args):
|
||||
arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8)
|
||||
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed))
|
||||
cg.add(var.set_values_static(arr, len(values)))
|
||||
return await register_client_action(var, config, args, [])
|
||||
return await register_client_action(
|
||||
var, config, args, [], command_direction="write"
|
||||
)
|
||||
|
||||
|
||||
# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single
|
||||
|
||||
@@ -68,8 +68,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
|
||||
/// resolves through on_sent() alone), so resolve refusals here via on_not_sent.
|
||||
/// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and
|
||||
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
|
||||
void send_or_resolve_(std::span<const uint8_t> pdu) {
|
||||
if (!this->queue_pdu(pdu))
|
||||
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
if (!this->queue_pdu(pdu, options))
|
||||
this->on_not_sent(pdu);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,26 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
|
||||
retry_func_t retry_func_{nullptr};
|
||||
};
|
||||
|
||||
/// The read-side per-command options (modbus::CommandOptions), declared once for every action that
|
||||
/// sends a read. Each option is templatable, so it cannot be built in Python the way modbus_controller
|
||||
/// builds its static struct; declaring the values here instead of per action means a new read option
|
||||
/// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up.
|
||||
/// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python
|
||||
/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option
|
||||
/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members.
|
||||
template<typename... Ts> class ReadCommandOptions {
|
||||
public:
|
||||
// Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips
|
||||
// it for mutating function codes at the door (see modbus::CommandOptions).
|
||||
TEMPLATABLE_VALUE(bool, continuous)
|
||||
|
||||
protected:
|
||||
/// The options for this send, with every templatable value resolved against the action's arguments.
|
||||
modbus::CommandOptions command_options_(const Ts &...x) const {
|
||||
return {.continuous = this->continuous_.value(x...)};
|
||||
}
|
||||
};
|
||||
|
||||
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
|
||||
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
|
||||
/// non-standard/custom transactions pass through untouched.
|
||||
@@ -87,7 +107,8 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
|
||||
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
|
||||
/// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check
|
||||
/// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated.
|
||||
template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class ModbusClientSendAction : public ClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
|
||||
|
||||
@@ -95,7 +116,7 @@ template<typename... Ts> class ModbusClientSendAction : public ClientActionBase<
|
||||
return &this->response_trigger_;
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); }
|
||||
void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); }
|
||||
|
||||
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
|
||||
this->response_trigger_.trigger(request_pdu, response_pdu);
|
||||
@@ -140,7 +161,8 @@ template<typename... Ts> class TypedClientActionBase : public ClientActionBase<T
|
||||
|
||||
/// modbus_client.read_holding_registers / read_input_registers: on_response delivers the registers in
|
||||
/// host byte order as `values` (only valid for the duration of the trigger).
|
||||
template<typename... Ts> class ReadRegistersAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts>
|
||||
class ReadRegistersAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
|
||||
public:
|
||||
explicit ReadRegistersAction(bool holding) : holding_(holding) {}
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
@@ -152,7 +174,8 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
|
||||
const auto function_code =
|
||||
this->holding_ ? modbus::FunctionCode::READ_HOLDING_REGISTERS : modbus::FunctionCode::READ_INPUT_REGISTERS;
|
||||
this->send_or_resolve_(
|
||||
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
|
||||
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
|
||||
this->command_options_(x...));
|
||||
}
|
||||
void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span<const uint16_t> registers,
|
||||
modbus::ResponseStatus status) override {
|
||||
@@ -167,7 +190,7 @@ template<typename... Ts> class ReadRegistersAction : public TypedClientActionBas
|
||||
|
||||
/// modbus_client.read_coils / read_discrete_inputs: on_response delivers the bits as a PackedBits view
|
||||
/// (bit 0 = the bit at start_address; only valid for the duration of the trigger).
|
||||
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...> {
|
||||
template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts...>, public ReadCommandOptions<Ts...> {
|
||||
public:
|
||||
explicit ReadBitsAction(bool coils) : coils_(coils) {}
|
||||
TEMPLATABLE_VALUE(uint16_t, start_address)
|
||||
@@ -179,7 +202,8 @@ template<typename... Ts> class ReadBitsAction : public TypedClientActionBase<Ts.
|
||||
const auto function_code =
|
||||
this->coils_ ? modbus::FunctionCode::READ_COILS : modbus::FunctionCode::READ_DISCRETE_INPUTS;
|
||||
this->send_or_resolve_(
|
||||
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)));
|
||||
modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...)),
|
||||
this->command_options_(x...));
|
||||
}
|
||||
void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits,
|
||||
modbus::ResponseStatus status) override {
|
||||
|
||||
@@ -4,8 +4,16 @@ from esphome.components import runtime_image
|
||||
from esphome.components.const import CONF_REQUEST_HEADERS
|
||||
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
|
||||
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
|
||||
from esphome.components.runtime_image import IMAGE_FORMATS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL
|
||||
from esphome.const import (
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_FORMAT,
|
||||
CONF_ID,
|
||||
CONF_ON_ERROR,
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import ID, Lambda
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
@@ -31,7 +39,6 @@ ReleaseImageAction = online_image_ns.class_(
|
||||
"OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage)
|
||||
)
|
||||
|
||||
|
||||
ONLINE_IMAGE_SCHEMA = (
|
||||
runtime_image.runtime_image_schema(OnlineImage)
|
||||
.extend(
|
||||
@@ -39,6 +46,8 @@ ONLINE_IMAGE_SCHEMA = (
|
||||
# Online Image specific options
|
||||
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
|
||||
cv.Required(CONF_URL): cv.url,
|
||||
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
|
||||
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
|
||||
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
|
||||
cv.Schema({cv.string: cv.templatable(cv.string)})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#include "online_image.h"
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
|
||||
static const char *const TAG = "online_image";
|
||||
static const char *const CONTENT_TYPE_HEADER_NAME = "content-type";
|
||||
static const char *const ETAG_HEADER_NAME = "etag";
|
||||
static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match";
|
||||
static const char *const LAST_MODIFIED_HEADER_NAME = "last-modified";
|
||||
@@ -62,7 +64,8 @@ void OnlineImage::update() {
|
||||
|
||||
// Add Accept header based on image format
|
||||
const char *accept_mime_type;
|
||||
switch (this->get_format()) {
|
||||
runtime_image::ImageFormat format = this->get_format();
|
||||
switch (format) {
|
||||
#ifdef USE_RUNTIME_IMAGE_BMP
|
||||
case runtime_image::BMP:
|
||||
accept_mime_type = "image/bmp,*/*;q=0.8";
|
||||
@@ -89,8 +92,8 @@ void OnlineImage::update() {
|
||||
headers.push_back(http_request::Header{header.first, header.second.value()});
|
||||
}
|
||||
|
||||
this->downloader_ = this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME});
|
||||
|
||||
this->downloader_ =
|
||||
this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME, CONTENT_TYPE_HEADER_NAME});
|
||||
if (this->downloader_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Download failed.");
|
||||
this->end_connection_();
|
||||
@@ -115,17 +118,54 @@ void OnlineImage::update() {
|
||||
|
||||
ESP_LOGD(TAG, "Starting download");
|
||||
size_t total_size = this->downloader_->content_length;
|
||||
ESP_LOGV(TAG, "Content-Length: %zu", total_size);
|
||||
|
||||
if (format == runtime_image::AUTO) {
|
||||
// Try to auto-detect format from Content-Type header
|
||||
auto content_type_header = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME);
|
||||
const char *content_type = content_type_header.c_str();
|
||||
ESP_LOGV(TAG, "Content-Type: %s", content_type);
|
||||
// Includes aliases seen from real servers (older IIS, CDNs, S3)
|
||||
if (str_contains_ignore_case(content_type, "image/bmp") ||
|
||||
str_contains_ignore_case(content_type, "image/x-ms-bmp") ||
|
||||
str_contains_ignore_case(content_type, "image/x-bmp")) {
|
||||
format = runtime_image::BMP;
|
||||
} else if (str_contains_ignore_case(content_type, "image/jpeg") ||
|
||||
str_contains_ignore_case(content_type, "image/jpg")) {
|
||||
format = runtime_image::JPEG;
|
||||
} else if (str_contains_ignore_case(content_type, "image/png") ||
|
||||
str_contains_ignore_case(content_type, "image/x-png")) {
|
||||
format = runtime_image::PNG;
|
||||
} else if (str_contains_ignore_case(content_type, "image/")) {
|
||||
ESP_LOGW(TAG, "Unsupported image type: '%s'", content_type);
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
return;
|
||||
} else {
|
||||
// TODO: implement auto-detection in runtime_image by sniffing the first few bytes of the image data
|
||||
if (content_type_header.empty()) {
|
||||
ESP_LOGW(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly");
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Could not determine image format from Content-Type: '%s'. Set `format:` explicitly",
|
||||
content_type);
|
||||
}
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGD(TAG, "Using image format: %d", format);
|
||||
|
||||
// Initialize decoder with the known format
|
||||
if (!this->begin_decode(total_size)) {
|
||||
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", this->get_format());
|
||||
if (!this->begin_decode(total_size, format)) {
|
||||
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", format);
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
return;
|
||||
}
|
||||
|
||||
// JPEG requires the complete image in the download buffer before decoding
|
||||
if (this->get_format() == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
|
||||
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
|
||||
this->download_buffer_.resize(total_size);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_defines,
|
||||
filter_source_files_from_platform,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
@@ -171,24 +174,17 @@ _filter_backend_source_files = filter_source_files_from_platform(
|
||||
)
|
||||
|
||||
|
||||
# USE_OTA_SIGNED_VERIFICATION_MULTI_KEY is set only on ESP32/IDF;
|
||||
# USE_OTA_PARTITIONS is set by the esphome OTA platform when
|
||||
# allow_partition_access is enabled.
|
||||
_filter_define_source_files = filter_source_files_from_defines(
|
||||
{
|
||||
"ota_signature_esp_idf.cpp": "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY",
|
||||
"ota_bootloader_esp_idf.cpp": "USE_OTA_PARTITIONS",
|
||||
"ota_partitions_esp_idf.cpp": "USE_OTA_PARTITIONS",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
files = _filter_backend_source_files()
|
||||
# ota_signature_esp_idf.cpp implements multi-key OTA signature verification,
|
||||
# compiled only when the esp32 component enables it (external RSA signed
|
||||
# OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on
|
||||
# ESP32/IDF, so this also excludes the file on every other platform. Filter
|
||||
# it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened
|
||||
# and parsed on every build.
|
||||
if not any(
|
||||
define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY"
|
||||
for define in CORE.defines
|
||||
):
|
||||
files.append("ota_signature_esp_idf.cpp")
|
||||
# ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully
|
||||
# #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when
|
||||
# allow_partition_access is enabled). Filter them out otherwise for the
|
||||
# same reason as above.
|
||||
if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines):
|
||||
files.append("ota_bootloader_esp_idf.cpp")
|
||||
files.append("ota_partitions_esp_idf.cpp")
|
||||
return files
|
||||
return _filter_backend_source_files() + _filter_define_source_files()
|
||||
|
||||
@@ -66,6 +66,19 @@ enum OTAResponseTypes {
|
||||
*/
|
||||
bool version_is_older(const char *candidate, const char *reference);
|
||||
|
||||
// 64 KiB flash block; the erase granularity the ESP-IDF backend erases ahead with.
|
||||
static constexpr size_t OTA_BLOCK_ERASE_SIZE = 64 * 1024;
|
||||
|
||||
/** Target erased watermark for lazy block erase-ahead.
|
||||
*
|
||||
* Rounds the write end offset up to a block boundary, clamped to the partition
|
||||
* size. Platform-independent so the arithmetic is host-testable.
|
||||
*/
|
||||
constexpr size_t next_erase_end(size_t write_end, size_t partition_size) {
|
||||
const size_t rounded = (write_end + OTA_BLOCK_ERASE_SIZE - 1) & ~(OTA_BLOCK_ERASE_SIZE - 1);
|
||||
return rounded < partition_size ? rounded : partition_size;
|
||||
}
|
||||
|
||||
enum OTAState {
|
||||
OTA_COMPLETED = 0,
|
||||
OTA_STARTED,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <sdkconfig.h>
|
||||
#include <spi_flash_mmap.h>
|
||||
#ifdef USE_OTA_DOWNGRADE_PROTECTION
|
||||
#include <esp_app_desc.h>
|
||||
@@ -60,27 +60,38 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
|
||||
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
|
||||
}
|
||||
|
||||
// esp_ota_begin() erases the destination region, which blocks loopTask and
|
||||
// scales with the erase size -- a fixed watchdog overruns on large OTA slots.
|
||||
// An unknown size (0, e.g. web_server uploads) erases the whole partition, so
|
||||
// budget against the bytes actually erased. ~10ms/KiB (conservative
|
||||
// ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still
|
||||
// resets rather than hanging forever.
|
||||
size_t erase_size = image_size;
|
||||
if (erase_size == 0 || erase_size > this->partition_->size) {
|
||||
erase_size = this->partition_->size;
|
||||
// Both lazy-erase paths below replace esp_ota_begin()'s blocking full erase.
|
||||
// Size check replaces the one that erase performed (0 = unknown size,
|
||||
// e.g. web_server uploads).
|
||||
if (image_size != 0 && image_size > this->partition_->size) {
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
}
|
||||
const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10;
|
||||
watchdog::WatchdogManager watchdog(erase_budget_ms);
|
||||
esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_);
|
||||
this->written_ = 0;
|
||||
esp_err_t err;
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
this->erased_end_ = 0;
|
||||
// Unlike esp_ota_begin(), esp_ota_resume() does not reject a running app in
|
||||
// ESP_OTA_IMG_PENDING_VERIFY; that state is unreachable here because the app
|
||||
// was marked valid at boot (esp32/hal.cpp) or just above under USE_OTA_ROLLBACK.
|
||||
// erase_size 0 (!= OTA_WITH_SEQUENTIAL_WRITES) means no erase; erase_ahead_() handles it
|
||||
err = esp_ota_resume(this->partition_, 0, 0, &this->update_handle_);
|
||||
#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
// esp_ota_begin() does this on IDF 5.5+; esp_ota_resume() does not. Prevents
|
||||
// booting a half-written slot after a crash mid-OTA. Not available on the
|
||||
// 5.3.3/5.4.2 backports, whose esp_ota_begin() did not invalidate either.
|
||||
if (err == ESP_OK) {
|
||||
esp_ota_invalidate_inactive_ota_data_slot();
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
err = esp_ota_begin(this->partition_, OTA_WITH_SEQUENTIAL_WRITES, &this->update_handle_);
|
||||
#endif
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err);
|
||||
ESP_LOGE(TAG, "OTA begin failed (err=0x%X)", err);
|
||||
esp_ota_abort(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
if (err == ESP_ERR_INVALID_SIZE) {
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
|
||||
if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
|
||||
return OTA_RESPONSE_ERROR_WRITING_FLASH;
|
||||
} else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) {
|
||||
// This error appears with 1 factory and 1 ota partition
|
||||
@@ -120,6 +131,17 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
|
||||
if (!this->is_app_or_bootloader_update_()) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
#endif
|
||||
// Overflow can only happen on unknown-size uploads (web_server); known
|
||||
// sizes were rejected in begin().
|
||||
if (this->written_ + len > this->partition_->size) {
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
}
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
OTAResponseTypes erase_result = this->erase_ahead_(len);
|
||||
if (erase_result != OTA_RESPONSE_OK) {
|
||||
return erase_result;
|
||||
}
|
||||
#endif
|
||||
esp_err_t err = esp_ota_write(this->update_handle_, data, len);
|
||||
this->md5_.add(data, len);
|
||||
@@ -127,14 +149,40 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
|
||||
ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err);
|
||||
if (err == ESP_ERR_OTA_VALIDATE_FAILED) {
|
||||
return OTA_RESPONSE_ERROR_MAGIC;
|
||||
} else if (err == ESP_ERR_INVALID_SIZE) {
|
||||
// Sequential-writes fallback: IDF's lazy erase reports overflow here
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
|
||||
return OTA_RESPONSE_ERROR_WRITING_FLASH;
|
||||
}
|
||||
return OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
this->written_ += len;
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
OTAResponseTypes IDFOTABackend::erase_ahead_(size_t len) {
|
||||
const size_t end = this->written_ + len;
|
||||
if (this->erased_end_ >= end) {
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
// Round up to a block boundary, clamped to the partition end; IDF splits the
|
||||
// range into 64 KiB block erases where aligned, sector erases elsewhere.
|
||||
const size_t erase_to = next_erase_end(end, this->partition_->size);
|
||||
// A block erase is one uninterruptible flash op (typically ~150 ms, seconds
|
||||
// on aged flash) and the transfer loop may not have fed the WDT for ~1s.
|
||||
watchdog::WatchdogManager watchdog(15000);
|
||||
esp_err_t err = esp_partition_erase_range(this->partition_, this->erased_end_, erase_to - this->erased_end_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_partition_erase_range failed (err=0x%X)", err);
|
||||
return err == ESP_ERR_INVALID_SIZE ? OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE : OTA_RESPONSE_ERROR_WRITING_FLASH;
|
||||
}
|
||||
this->erased_end_ = erase_to;
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
#endif
|
||||
|
||||
OTAResponseTypes IDFOTABackend::end() {
|
||||
if (this->md5_set_) {
|
||||
this->md5_.calculate();
|
||||
@@ -226,6 +274,10 @@ void IDFOTABackend::abort() {
|
||||
// or not an update is in flight.
|
||||
esp_ota_abort(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
this->written_ = 0;
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
this->erased_end_ = 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::ota
|
||||
|
||||
@@ -5,8 +5,18 @@
|
||||
#include "esphome/components/md5/md5.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
#include <esp_ota_ops.h>
|
||||
|
||||
// esp_ota_resume() (IDF 5.4.2+, backported to 5.3.3) provides a no-erase OTA
|
||||
// handle, letting write() block-erase 64 KiB ahead of the write cursor
|
||||
// (~4x faster than the per-sector lazy erase of OTA_WITH_SEQUENTIAL_WRITES,
|
||||
// used as fallback on older IDF).
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) || \
|
||||
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 3) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 0))
|
||||
#define USE_OTA_BLOCK_ERASE_AHEAD
|
||||
#endif
|
||||
|
||||
namespace esphome::ota {
|
||||
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
@@ -54,6 +64,9 @@ class IDFOTABackend final {
|
||||
#endif
|
||||
|
||||
private:
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
OTAResponseTypes erase_ahead_(size_t len);
|
||||
#endif
|
||||
#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
|
||||
// Accept an image signed by any key the running app trusts (up to 3 blocks),
|
||||
// so rotation and backup keys work. Fails closed. Covers app and bootloader.
|
||||
@@ -62,7 +75,11 @@ class IDFOTABackend final {
|
||||
// Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly.
|
||||
md5::MD5Digest md5_{};
|
||||
esp_ota_handle_t update_handle_{0};
|
||||
const esp_partition_t *partition_;
|
||||
const esp_partition_t *partition_{nullptr};
|
||||
size_t written_{0}; // Bytes handed to esp_ota_write()
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
size_t erased_end_{0}; // Erased up to this partition offset; must stay >= written_
|
||||
#endif
|
||||
char expected_bin_md5_[32];
|
||||
bool md5_set_{false};
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifdef USE_ESP32
|
||||
#include "ota_backend_esp_idf.h"
|
||||
|
||||
#include "esphome/components/watchdog/watchdog.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
@@ -69,12 +70,20 @@ OTAResponseTypes IDFOTABackend::setup_bootloader_staging_() {
|
||||
return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY;
|
||||
}
|
||||
// Erase full size of the bootloader partition in the staging partition
|
||||
// to avoid copying old data to the bootloader partition later
|
||||
// to avoid copying old data to the bootloader partition later. Up to
|
||||
// ESP_BOOTLOADER_SIZE of blocking erase; widen the WDT for its duration.
|
||||
watchdog::WatchdogManager watchdog(15000);
|
||||
esp_err_t err = esp_partition_erase_range(this->partition_, 0, this->bootloader_part_->size);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_partition_erase_range failed (err=0x%X)", err);
|
||||
// No critical error, don't return
|
||||
}
|
||||
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
|
||||
if (err == ESP_OK) {
|
||||
// Skip re-erasing the pre-erased staging region in erase_ahead_()
|
||||
this->erased_end_ = this->bootloader_part_->size;
|
||||
}
|
||||
#endif
|
||||
err = esp_ota_set_final_partition(this->update_handle_, this->bootloader_part_, false);
|
||||
if (err != ESP_OK) {
|
||||
esp_ota_abort(this->update_handle_);
|
||||
|
||||
@@ -211,7 +211,7 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) {
|
||||
bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) {
|
||||
// Verification re-hashes the full image (after esp_ota_end already did one
|
||||
// pass), which can approach the task WDT budget on a large app. Extend it for
|
||||
// the duration, mirroring the erase budget in begin().
|
||||
// the duration, scaled to the image size over a 15 s floor.
|
||||
const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10;
|
||||
watchdog::WatchdogManager watchdog(verify_budget_ms);
|
||||
|
||||
|
||||
@@ -58,6 +58,18 @@ class Format:
|
||||
"""Add defines and libraries needed for this format."""
|
||||
|
||||
|
||||
class AUTOFormat(Format):
|
||||
"""AUTO format - detect from MIME type."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("AUTO", None)
|
||||
|
||||
def actions(self) -> None:
|
||||
# dict.fromkeys dedupes the JPG/JPEG alias so each format runs once
|
||||
for image_format in dict.fromkeys(IMAGE_FORMATS.values()):
|
||||
image_format.actions()
|
||||
|
||||
|
||||
class BMPFormat(Format):
|
||||
"""BMP format decoder configuration."""
|
||||
|
||||
@@ -102,18 +114,25 @@ class PNGFormat(Format):
|
||||
cg.add_library("pngle", "1.1.0")
|
||||
|
||||
|
||||
# Registry of available formats
|
||||
# Decodable formats only; platforms that support runtime detection accept
|
||||
# "AUTO" in their own schema and get_format() resolves it
|
||||
_JPEG_FORMAT = JPEGFormat()
|
||||
IMAGE_FORMATS = {
|
||||
"BMP": BMPFormat(),
|
||||
"JPEG": JPEGFormat(),
|
||||
"JPEG": _JPEG_FORMAT,
|
||||
"JPG": _JPEG_FORMAT, # Alias for JPEG
|
||||
"PNG": PNGFormat(),
|
||||
"JPG": JPEGFormat(), # Alias for JPEG
|
||||
}
|
||||
|
||||
AUTO_FORMAT = AUTOFormat()
|
||||
|
||||
|
||||
def get_format(format_name: str) -> Format | None:
|
||||
"""Get a format instance by name."""
|
||||
return IMAGE_FORMATS.get(format_name.upper())
|
||||
name = format_name.upper()
|
||||
if name == "AUTO":
|
||||
return AUTO_FORMAT
|
||||
return IMAGE_FORMATS.get(name)
|
||||
|
||||
|
||||
def enable_format(format_name: str) -> Format | None:
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace esphome::runtime_image {
|
||||
* @brief Image format types that can be decoded dynamically.
|
||||
*/
|
||||
enum ImageFormat {
|
||||
/** Automatically detect from data. Not implemented yet. */
|
||||
/** Format is supplied per decode, e.g. detected from the Content-Type header
|
||||
* by online_image; sniffing the image data is not implemented. */
|
||||
AUTO,
|
||||
/** JPEG format. */
|
||||
JPEG,
|
||||
|
||||
@@ -171,22 +171,27 @@ void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on,
|
||||
// If no image is loaded and no placeholder, nothing to draw
|
||||
}
|
||||
|
||||
bool RuntimeImage::begin_decode(size_t expected_size) {
|
||||
bool RuntimeImage::begin_decode(size_t expected_size, ImageFormat format) {
|
||||
if (this->is_decoding()) {
|
||||
ESP_LOGW(TAG, "Decoding already in progress");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (format == AUTO && this->format_ != AUTO) {
|
||||
// Fall back to the configured format before the reuse check below
|
||||
format = this->format_;
|
||||
}
|
||||
|
||||
// An idle decoder for a different format cannot be reused
|
||||
if (this->decoder_ != nullptr && this->decoder_->get_format() != this->format_) {
|
||||
ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), this->format_);
|
||||
if (this->decoder_ != nullptr && this->decoder_->get_format() != format) {
|
||||
ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), format);
|
||||
this->decoder_ = nullptr;
|
||||
}
|
||||
|
||||
if (!this->decoder_) {
|
||||
this->decoder_ = this->create_decoder_(this->format_);
|
||||
this->decoder_ = this->create_decoder_(format);
|
||||
if (!this->decoder_) {
|
||||
ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_);
|
||||
ESP_LOGE(TAG, "Failed to create decoder for format %d", format);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -364,6 +369,9 @@ std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_(ImageFormat format)
|
||||
case PNG:
|
||||
return make_unique<PngDecoder>(this);
|
||||
#endif
|
||||
case AUTO:
|
||||
ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration");
|
||||
return nullptr;
|
||||
default:
|
||||
ESP_LOGE(TAG, "Unsupported image format: %d", format);
|
||||
return nullptr;
|
||||
|
||||
@@ -62,9 +62,10 @@ class RuntimeImage : public image::Image {
|
||||
* @brief Begin decoding an image.
|
||||
*
|
||||
* @param expected_size Optional hint about the expected data size.
|
||||
* @param format The image format to decode (defaults to AUTO, which uses the value set at construction).
|
||||
* @return true if decoder was successfully initialized.
|
||||
*/
|
||||
bool begin_decode(size_t expected_size = 0);
|
||||
bool begin_decode(size_t expected_size = 0, ImageFormat format = AUTO);
|
||||
|
||||
/**
|
||||
* @brief Feed data to the decoder.
|
||||
@@ -103,6 +104,7 @@ class RuntimeImage : public image::Image {
|
||||
/**
|
||||
* @brief Get the image format.
|
||||
*/
|
||||
/// Configured format; a format resolved per decode lives on the active decoder
|
||||
ImageFormat get_format() const { return this->format_; }
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
from esphome.components.const import CONF_B_CONSTANT
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ABOVE,
|
||||
@@ -1303,3 +1304,8 @@ def _lstsq(a, b):
|
||||
@coroutine_with_priority(CoroPriority.CORE)
|
||||
async def to_code(config):
|
||||
cg.add_global(sensor_ns.using)
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"filter.cpp": "USE_SENSOR_FILTER"}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
@@ -256,3 +257,8 @@ async def text_sensor_state_to_code(config, condition_id, template_arg, args):
|
||||
templ = await cg.templatable(config[CONF_STATE], args, cg.std_string)
|
||||
cg.add(var.set_state(templ))
|
||||
return var
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"filter.cpp": "USE_TEXT_SENSOR_FILTER"}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, time
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_TIME_ID,
|
||||
@@ -10,7 +11,6 @@ from esphome.const import (
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_SECOND,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
uptime_ns = cg.esphome_ns.namespace("uptime")
|
||||
UptimeSecondsSensor = uptime_ns.class_(
|
||||
@@ -62,9 +62,6 @@ async def to_code(config):
|
||||
cg.add(var.set_time(time_id))
|
||||
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
# uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it
|
||||
# when no time component is configured.
|
||||
if not any(define.name == "USE_TIME" for define in CORE.defines):
|
||||
return ["uptime_timestamp_sensor.cpp"]
|
||||
return []
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"uptime_timestamp_sensor.cpp": "USE_TIME"}
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ class ListEntitiesIterator final : public ComponentIterator {
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
bool completed() { return this->state_ == IteratorState::NONE; }
|
||||
|
||||
protected:
|
||||
const WebServer *web_server_;
|
||||
|
||||
@@ -214,8 +214,8 @@ void DeferredUpdateEventSource::process_deferred_queue_() {
|
||||
|
||||
void DeferredUpdateEventSource::loop() {
|
||||
process_deferred_queue_();
|
||||
if (!this->entities_iterator_.completed())
|
||||
this->entities_iterator_.advance();
|
||||
// One step per loop; refusals retry next pass
|
||||
this->entities_iterator_.try_advance(1);
|
||||
}
|
||||
|
||||
void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type,
|
||||
@@ -321,12 +321,6 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource
|
||||
#endif
|
||||
|
||||
source->entities_iterator_.begin(ws->include_internal_);
|
||||
|
||||
// just dump them all up-front and take advantage of the deferred queue
|
||||
// on second thought that takes too long, but leaving the commented code here for debug purposes
|
||||
// while(!source->entities_iterator_.completed()) {
|
||||
// source->entities_iterator_.advance();
|
||||
//}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -935,8 +935,8 @@ void AsyncEventSourceResponse::process_buffer_() {
|
||||
void AsyncEventSourceResponse::loop() {
|
||||
process_buffer_();
|
||||
process_deferred_queue_();
|
||||
if (!this->entities_iterator_.completed())
|
||||
this->entities_iterator_.advance();
|
||||
// One step per loop; refusals retry next pass
|
||||
this->entities_iterator_.try_advance(1);
|
||||
}
|
||||
|
||||
bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id,
|
||||
|
||||
@@ -117,12 +117,6 @@ class AsyncWebServerRequest {
|
||||
/// Write URL (without query string) to buffer, returns StringRef pointing to buffer.
|
||||
/// URL is decoded (e.g., %20 -> space).
|
||||
StringRef url_to(std::span<char, URL_BUF_SIZE> buffer) const;
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use url_to() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
std::string url() const {
|
||||
char buffer[URL_BUF_SIZE];
|
||||
return std::string(this->url_to(buffer));
|
||||
}
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
size_t contentLength() const { return this->req_->content_len; }
|
||||
|
||||
|
||||
@@ -618,8 +618,6 @@ static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
|
||||
}
|
||||
#endif
|
||||
|
||||
float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; }
|
||||
|
||||
void WiFiComponent::setup() {
|
||||
this->wifi_pre_setup_();
|
||||
|
||||
@@ -931,10 +929,6 @@ void WiFiComponent::loop() {
|
||||
|
||||
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
|
||||
|
||||
#ifdef USE_WIFI_11KV_SUPPORT
|
||||
void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
|
||||
void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
|
||||
#endif
|
||||
network::IPAddresses WiFiComponent::get_ip_addresses() {
|
||||
if (this->has_sta())
|
||||
return this->wifi_sta_ip_addresses();
|
||||
@@ -1327,8 +1321,6 @@ void WiFiComponent::disable() {
|
||||
this->wifi_mode_(false, false);
|
||||
}
|
||||
|
||||
bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }
|
||||
|
||||
void WiFiComponent::start_scanning() {
|
||||
this->action_started_ = millis();
|
||||
ESP_LOGD(TAG, "Starting scan");
|
||||
@@ -2196,7 +2188,6 @@ void WiFiComponent::retry_connect() {
|
||||
}
|
||||
}
|
||||
|
||||
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
|
||||
void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
|
||||
this->power_save_ = power_save;
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
|
||||
@@ -2204,8 +2195,6 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
|
||||
#endif
|
||||
}
|
||||
|
||||
void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; }
|
||||
|
||||
bool WiFiComponent::is_captive_portal_active_() {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active();
|
||||
@@ -2324,33 +2313,6 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
|
||||
void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); }
|
||||
void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
|
||||
void WiFiAP::clear_bssid() { this->bssid_ = {}; }
|
||||
void WiFiAP::set_password(const std::string &password) {
|
||||
this->password_ = CompactString(password.c_str(), password.size());
|
||||
}
|
||||
void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
void WiFiAP::set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
|
||||
#endif
|
||||
void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; }
|
||||
void WiFiAP::clear_channel() { this->channel_ = 0; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
void WiFiAP::set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
|
||||
#endif
|
||||
void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; }
|
||||
const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; }
|
||||
bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
|
||||
#endif
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const optional<ManualIP> &WiFiAP::get_manual_ip() const { return this->manual_ip_; }
|
||||
#endif
|
||||
bool WiFiAP::get_hidden() const { return this->hidden_; }
|
||||
|
||||
WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi,
|
||||
bool with_auth, bool is_hidden)
|
||||
: bssid_(bssid),
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
@@ -261,38 +262,38 @@ class WiFiAP {
|
||||
friend class WiFiScanResult;
|
||||
|
||||
public:
|
||||
void set_ssid(const std::string &ssid);
|
||||
void set_ssid(const char *ssid);
|
||||
void set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
|
||||
void set_ssid(const char *ssid) { this->set_ssid(StringRef(ssid)); }
|
||||
void set_ssid(StringRef ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
|
||||
void set_bssid(const bssid_t &bssid);
|
||||
void clear_bssid();
|
||||
void set_password(const std::string &password);
|
||||
void set_password(const char *password);
|
||||
void set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
|
||||
void clear_bssid() { this->bssid_ = {}; }
|
||||
void set_password(const std::string &password) { this->password_ = CompactString(password.c_str(), password.size()); }
|
||||
void set_password(const char *password) { this->set_password(StringRef(password)); }
|
||||
void set_password(StringRef password) { this->password_ = CompactString(password.c_str(), password.size()); }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
void set_eap(optional<EAPAuth> eap_auth);
|
||||
void set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
|
||||
#endif // USE_WIFI_WPA2_EAP
|
||||
void set_channel(uint8_t channel);
|
||||
void clear_channel();
|
||||
void set_channel(uint8_t channel) { this->channel_ = channel; }
|
||||
void clear_channel() { this->channel_ = 0; }
|
||||
void set_priority(int8_t priority) { priority_ = priority; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
void set_manual_ip(optional<ManualIP> manual_ip);
|
||||
void set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
|
||||
#endif
|
||||
void set_hidden(bool hidden);
|
||||
void set_hidden(bool hidden) { this->hidden_ = hidden; }
|
||||
StringRef get_ssid() const { return this->ssid_.ref(); }
|
||||
StringRef get_password() const { return this->password_.ref(); }
|
||||
const bssid_t &get_bssid() const;
|
||||
bool has_bssid() const;
|
||||
const bssid_t &get_bssid() const { return this->bssid_; }
|
||||
bool has_bssid() const { return this->bssid_ != bssid_t{}; }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
const optional<EAPAuth> &get_eap() const;
|
||||
const optional<EAPAuth> &get_eap() const { return this->eap_; }
|
||||
#endif // USE_WIFI_WPA2_EAP
|
||||
uint8_t get_channel() const { return this->channel_; }
|
||||
bool has_channel() const { return this->channel_ != 0; }
|
||||
int8_t get_priority() const { return priority_; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const optional<ManualIP> &get_manual_ip() const;
|
||||
const optional<ManualIP> &get_manual_ip() const { return this->manual_ip_; }
|
||||
#endif
|
||||
bool get_hidden() const;
|
||||
bool get_hidden() const { return this->hidden_; }
|
||||
|
||||
protected:
|
||||
CompactString ssid_;
|
||||
@@ -442,6 +443,7 @@ class WiFiComponent final : public Component {
|
||||
void set_sta(const WiFiAP &ap);
|
||||
// Returns a copy of the currently selected AP configuration
|
||||
WiFiAP get_sta() const;
|
||||
// init_sta/add_sta kept out of line: inlining them into the generated setup() grows flash
|
||||
void init_sta(size_t count);
|
||||
void add_sta(const WiFiAP &ap);
|
||||
void clear_sta();
|
||||
@@ -461,7 +463,7 @@ class WiFiComponent final : public Component {
|
||||
|
||||
void enable();
|
||||
void disable();
|
||||
bool is_disabled();
|
||||
bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }
|
||||
void start_scanning();
|
||||
void check_scanning_finished();
|
||||
void start_connecting(const WiFiAP &ap);
|
||||
@@ -472,7 +474,7 @@ class WiFiComponent final : public Component {
|
||||
|
||||
void retry_connect();
|
||||
|
||||
void set_reboot_timeout(uint32_t reboot_timeout);
|
||||
void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
|
||||
|
||||
bool is_connected() const { return this->connected_; }
|
||||
|
||||
@@ -492,7 +494,7 @@ class WiFiComponent final : public Component {
|
||||
void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; }
|
||||
#endif
|
||||
|
||||
void set_passive_scan(bool passive);
|
||||
void set_passive_scan(bool passive) { this->passive_scan_ = passive; }
|
||||
|
||||
void save_wifi_sta(const std::string &ssid, const std::string &password);
|
||||
void save_wifi_sta(const char *ssid, const char *password);
|
||||
@@ -506,7 +508,7 @@ class WiFiComponent final : public Component {
|
||||
void dump_config() override;
|
||||
void restart_adapter();
|
||||
/// WIFI setup_priority.
|
||||
float get_setup_priority() const override;
|
||||
float get_setup_priority() const override { return setup_priority::WIFI; }
|
||||
/// Reconnect WiFi if required.
|
||||
void loop() override;
|
||||
|
||||
@@ -515,8 +517,8 @@ class WiFiComponent final : public Component {
|
||||
bool is_ap_active() const { return this->ap_started_; }
|
||||
|
||||
#ifdef USE_WIFI_11KV_SUPPORT
|
||||
void set_btm(bool btm);
|
||||
void set_rrm(bool rrm);
|
||||
void set_btm(bool btm) { this->btm_ = btm; }
|
||||
void set_rrm(bool rrm) { this->rrm_ = rrm; }
|
||||
#endif
|
||||
|
||||
network::IPAddress get_dns_address(int num);
|
||||
@@ -550,9 +552,6 @@ class WiFiComponent final : public Component {
|
||||
void set_sta_priority(bssid_t bssid, int8_t priority);
|
||||
|
||||
network::IPAddresses wifi_sta_ip_addresses();
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use wifi_ssid_to() instead. Removed in 2026.9.0", "2026.3.0")
|
||||
std::string wifi_ssid();
|
||||
/// Write SSID to buffer without heap allocation.
|
||||
/// Returns pointer to buffer, or empty string if not connected.
|
||||
const char *wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer);
|
||||
|
||||
@@ -944,16 +944,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
}
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() {
|
||||
struct station_config conf {};
|
||||
if (!wifi_station_get_config(&conf)) {
|
||||
return "";
|
||||
}
|
||||
// conf.ssid is uint8[32], not null-terminated if full
|
||||
auto *ssid_s = reinterpret_cast<const char *>(conf.ssid);
|
||||
size_t len = strnlen(ssid_s, sizeof(conf.ssid));
|
||||
return {ssid_s, len};
|
||||
}
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
struct station_config conf {};
|
||||
if (!wifi_station_get_config(&conf)) {
|
||||
|
||||
@@ -1237,18 +1237,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
std::copy(info.bssid, info.bssid + 6, bssid.begin());
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() {
|
||||
wifi_ap_record_t info{};
|
||||
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
|
||||
if (err != ESP_OK) {
|
||||
// Very verbose only: this is expected during dump_config() before connection is established (PR #9823)
|
||||
ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
|
||||
return "";
|
||||
}
|
||||
auto *ssid_s = reinterpret_cast<const char *>(info.ssid);
|
||||
size_t len = strnlen(ssid_s, sizeof(info.ssid));
|
||||
return {ssid_s, len};
|
||||
}
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
wifi_ap_record_t info{};
|
||||
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
|
||||
|
||||
@@ -762,7 +762,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
}
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
#ifdef USE_BK72XX
|
||||
LinkStatusTypeDef link_status{};
|
||||
|
||||
@@ -265,7 +265,6 @@ bssid_t WiFiComponent::wifi_bssid() {
|
||||
bssid[i] = raw_bssid[i];
|
||||
return bssid;
|
||||
}
|
||||
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
|
||||
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
// TODO: Find direct CYW43 API to avoid Arduino String allocation
|
||||
String ssid = WiFi.SSID();
|
||||
|
||||
@@ -151,6 +151,31 @@ def filter_source_files_from_platform(
|
||||
return filter_source_files
|
||||
|
||||
|
||||
def filter_source_files_from_defines(
|
||||
files_map: dict[str, str | tuple[str, ...]],
|
||||
) -> Callable[[], list[str]]:
|
||||
"""Helper to build a FILTER_SOURCE_FILES function from a define mapping.
|
||||
|
||||
Args:
|
||||
files_map: Dict mapping filename to the define name (or tuple of
|
||||
define names) that keeps the file in the build; the file is
|
||||
excluded when none of its defines is set for the current config.
|
||||
|
||||
Returns:
|
||||
Function that returns the files to exclude for the current config.
|
||||
"""
|
||||
|
||||
def filter_source_files() -> list[str]:
|
||||
defines = {define.name for define in CORE.defines}
|
||||
return [
|
||||
filename
|
||||
for filename, needed in files_map.items()
|
||||
if defines.isdisjoint((needed,) if isinstance(needed, str) else needed)
|
||||
]
|
||||
|
||||
return filter_source_files
|
||||
|
||||
|
||||
def get_logger_level() -> str:
|
||||
"""Get the configured logger level.
|
||||
|
||||
|
||||
@@ -22,23 +22,23 @@ void ComponentIterator::advance_platform_() {
|
||||
this->at_ = 0;
|
||||
}
|
||||
|
||||
void ComponentIterator::advance() {
|
||||
bool ComponentIterator::advance_step_() {
|
||||
switch (this->state_) {
|
||||
case IteratorState::NONE:
|
||||
// not started
|
||||
return;
|
||||
return false;
|
||||
case IteratorState::BEGIN:
|
||||
if (this->on_begin()) {
|
||||
advance_platform_();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
return false;
|
||||
|
||||
// Entity iterator cases (generated from entity_types.h)
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
case IteratorState::upper: \
|
||||
this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular); \
|
||||
break;
|
||||
return this->process_platform_item_(App.get_##plural(), &ComponentIterator::on_##singular);
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
@@ -48,26 +48,29 @@ void ComponentIterator::advance() {
|
||||
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
case IteratorState::SERVICE:
|
||||
this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
|
||||
break;
|
||||
return this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service);
|
||||
#endif
|
||||
|
||||
#ifdef USE_CAMERA
|
||||
case IteratorState::CAMERA: {
|
||||
camera::Camera *camera_instance = camera::Camera::instance();
|
||||
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_)) {
|
||||
this->on_camera(camera_instance);
|
||||
if (camera_instance != nullptr && (!camera_instance->is_internal() || this->include_internal_) &&
|
||||
!this->on_camera(camera_instance)) {
|
||||
return false;
|
||||
}
|
||||
advance_platform_();
|
||||
} break;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
case IteratorState::MAX:
|
||||
if (this->on_end()) {
|
||||
this->state_ = IteratorState::NONE;
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ComponentIterator::on_end() { return true; }
|
||||
|
||||
@@ -30,7 +30,23 @@ class RadioFrequency;
|
||||
class ComponentIterator {
|
||||
public:
|
||||
void begin(bool include_internal = false);
|
||||
void advance();
|
||||
/// Run up to max_steps iteration steps; stops early when iteration
|
||||
/// completes or a callback refuses (that step is retried on the next
|
||||
/// call). Inline so an idle (completed) iterator costs one compare, no call.
|
||||
ESPHOME_ALWAYS_INLINE void try_advance(size_t max_steps) {
|
||||
size_t steps = 0;
|
||||
while (steps < max_steps && !this->completed()) {
|
||||
this->yield_requested_ = false;
|
||||
if (!this->advance_step_())
|
||||
break;
|
||||
steps++;
|
||||
if (this->yield_requested_)
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Remove before 2027.3.0
|
||||
ESPDEPRECATED("Use try_advance() instead. Removed in 2027.3.0", "2026.8.1")
|
||||
void advance() { this->try_advance(1); }
|
||||
bool completed() const { return this->state_ == IteratorState::NONE; }
|
||||
virtual bool on_begin();
|
||||
// Pure virtual entity callbacks (generated from entity_types.h)
|
||||
@@ -73,23 +89,34 @@ class ComponentIterator {
|
||||
#endif
|
||||
MAX,
|
||||
};
|
||||
/// End the current try_advance() pass after this step; lets callbacks
|
||||
/// that write directly to the socket cap direct writes per pass.
|
||||
void yield_after_step_() { this->yield_requested_ = true; }
|
||||
|
||||
uint16_t at_{0}; // Supports up to 65,535 entities per type
|
||||
IteratorState state_{IteratorState::NONE};
|
||||
bool include_internal_{false};
|
||||
bool yield_requested_ : 1 {false};
|
||||
bool include_internal_ : 1 {false};
|
||||
|
||||
template<typename Container>
|
||||
void process_platform_item_(const Container &items,
|
||||
bool process_platform_item_(const Container &items,
|
||||
bool (ComponentIterator::*on_item)(typename Container::value_type)) {
|
||||
if (this->at_ >= items.size()) {
|
||||
this->advance_platform_();
|
||||
} else {
|
||||
typename Container::value_type item = items[this->at_];
|
||||
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
|
||||
this->at_++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
typename Container::value_type item = items[this->at_];
|
||||
if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) {
|
||||
this->at_++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// One iteration step; false if no progress was made (callback refused
|
||||
/// or iterator not running).
|
||||
bool advance_step_();
|
||||
|
||||
void advance_platform_();
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
#define USE_ALARM_CONTROL_PANEL
|
||||
#define USE_AREAS
|
||||
#define USE_BINARY_SENSOR
|
||||
#define USE_BINARY_SENSOR_CLICK_TRIGGER
|
||||
#define USE_BINARY_SENSOR_FILTER
|
||||
#define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
|
||||
#define USE_BLE_DEVICE_IRK
|
||||
#define USE_BUTTON
|
||||
#define USE_CAMERA
|
||||
@@ -281,6 +283,7 @@
|
||||
// ESP32-specific feature flags
|
||||
#ifdef USE_ESP32
|
||||
#define USE_ESP32_CRASH_HANDLER
|
||||
#define USE_ESP32_INTERNAL_GPIO
|
||||
#define USE_MQTT_IDF_ENQUEUE
|
||||
#define USE_ESPHOME_TASK_LOG_BUFFER
|
||||
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
|
||||
|
||||
@@ -80,24 +80,6 @@ const char *EntityBase::get_device_class_to([[maybe_unused]] std::span<char, MAX
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef USE_ESP8266
|
||||
// Deprecated device class accessors — not available on ESP8266 (rodata is RAM)
|
||||
StringRef EntityBase::get_device_class_ref() const {
|
||||
#ifdef USE_ENTITY_DEVICE_CLASS
|
||||
return StringRef(entity_device_class_lookup(this->device_class_idx_));
|
||||
#else
|
||||
return StringRef(entity_device_class_lookup(0));
|
||||
#endif
|
||||
}
|
||||
std::string EntityBase::get_device_class() const {
|
||||
#ifdef USE_ENTITY_DEVICE_CLASS
|
||||
return std::string(entity_device_class_lookup(this->device_class_idx_));
|
||||
#else
|
||||
return std::string(entity_device_class_lookup(0));
|
||||
#endif
|
||||
}
|
||||
#endif // !USE_ESP8266
|
||||
|
||||
// Entity unit of measurement (from index)
|
||||
StringRef EntityBase::get_unit_of_measurement_ref() const {
|
||||
#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT
|
||||
@@ -106,10 +88,6 @@ StringRef EntityBase::get_unit_of_measurement_ref() const {
|
||||
return StringRef(entity_uom_lookup(0));
|
||||
#endif
|
||||
}
|
||||
std::string EntityBase::get_unit_of_measurement() const {
|
||||
return std::string(this->get_unit_of_measurement_ref().c_str());
|
||||
}
|
||||
|
||||
// Entity icon — buffer-based API for PROGMEM safety on ESP8266
|
||||
const char *EntityBase::get_icon_to([[maybe_unused]] std::span<char, MAX_ICON_LENGTH> buffer) const {
|
||||
#ifdef USE_ENTITY_ICON
|
||||
@@ -129,24 +107,6 @@ const char *EntityBase::get_icon_to([[maybe_unused]] std::span<char, MAX_ICON_LE
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef USE_ESP8266
|
||||
// Deprecated icon accessors — not available on ESP8266 (rodata is RAM)
|
||||
StringRef EntityBase::get_icon_ref() const {
|
||||
#ifdef USE_ENTITY_ICON
|
||||
return StringRef(entity_icon_lookup(this->icon_idx_));
|
||||
#else
|
||||
return StringRef(entity_icon_lookup(0));
|
||||
#endif
|
||||
}
|
||||
std::string EntityBase::get_icon() const {
|
||||
#ifdef USE_ENTITY_ICON
|
||||
return std::string(entity_icon_lookup(this->icon_idx_));
|
||||
#else
|
||||
return std::string(entity_icon_lookup(0));
|
||||
#endif
|
||||
}
|
||||
#endif // !USE_ESP8266
|
||||
|
||||
// Calculate Object ID Hash directly from name using snake_case + sanitize
|
||||
void EntityBase::calc_object_id_() {
|
||||
this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size());
|
||||
|
||||
@@ -109,60 +109,14 @@ class EntityBase {
|
||||
// On ESP8266: copies from PROGMEM to buffer, returns buffer pointer.
|
||||
const char *get_device_class_to(std::span<char, MAX_DEVICE_CLASS_LENGTH> buffer) const;
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, rodata is RAM. Device classes are in PROGMEM and cannot be accessed
|
||||
// directly as const char*. Use get_device_class_to() with a stack buffer instead.
|
||||
template<typename T = int> StringRef get_device_class_ref() const {
|
||||
static_assert(sizeof(T) == 0, "get_device_class_ref() unavailable on ESP8266 (rodata is RAM). "
|
||||
"Use get_device_class_to() with a stack buffer.");
|
||||
return StringRef("");
|
||||
}
|
||||
template<typename T = int> std::string get_device_class() const {
|
||||
static_assert(sizeof(T) == 0, "get_device_class() unavailable on ESP8266 (rodata is RAM). "
|
||||
"Use get_device_class_to() with a stack buffer.");
|
||||
return "";
|
||||
}
|
||||
#else
|
||||
// Deprecated: use get_device_class_to() instead. Device classes are in PROGMEM.
|
||||
ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
StringRef get_device_class_ref() const;
|
||||
ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
std::string get_device_class() const;
|
||||
#endif
|
||||
// Get unit of measurement as StringRef (from packed index)
|
||||
StringRef get_unit_of_measurement_ref() const;
|
||||
/// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref())
|
||||
ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be "
|
||||
"removed in ESPHome 2026.9.0",
|
||||
"2026.3.0")
|
||||
std::string get_unit_of_measurement() const;
|
||||
|
||||
// Get this entity's icon into a stack buffer.
|
||||
// On ESP32: returns pointer to PROGMEM string directly (buffer unused).
|
||||
// On ESP8266: copies from PROGMEM to buffer, returns buffer pointer.
|
||||
const char *get_icon_to(std::span<char, MAX_ICON_LENGTH> buffer) const;
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
// On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed
|
||||
// directly as const char*. Use get_icon_to() with a stack buffer instead.
|
||||
template<typename T = int> StringRef get_icon_ref() const {
|
||||
static_assert(sizeof(T) == 0,
|
||||
"get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
|
||||
return StringRef("");
|
||||
}
|
||||
template<typename T = int> std::string get_icon() const {
|
||||
static_assert(sizeof(T) == 0,
|
||||
"get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
|
||||
return "";
|
||||
}
|
||||
#else
|
||||
// Deprecated: use get_icon_to() instead. Icons are in PROGMEM.
|
||||
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
StringRef get_icon_ref() const;
|
||||
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
|
||||
std::string get_icon() const;
|
||||
#endif
|
||||
|
||||
#ifdef USE_DEVICES
|
||||
// Get this entity's device id
|
||||
uint32_t get_device_id() const {
|
||||
|
||||
@@ -723,23 +723,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector<int32_t>
|
||||
|
||||
// Colors
|
||||
|
||||
float gamma_correct(float value, float gamma) {
|
||||
if (value <= 0.0f)
|
||||
return 0.0f;
|
||||
if (gamma <= 0.0f)
|
||||
return value;
|
||||
|
||||
return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0
|
||||
}
|
||||
float gamma_uncorrect(float value, float gamma) {
|
||||
if (value <= 0.0f)
|
||||
return 0.0f;
|
||||
if (gamma <= 0.0f)
|
||||
return value;
|
||||
|
||||
return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0
|
||||
}
|
||||
|
||||
void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) {
|
||||
float max_color_value = std::max({red, green, blue});
|
||||
float min_color_value = std::min({red, green, blue});
|
||||
|
||||
@@ -1646,15 +1646,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector<int32_t>
|
||||
/// @name Colors
|
||||
///@{
|
||||
|
||||
/// Applies gamma correction of \p gamma to \p value.
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use LightState::gamma_correct_lut() instead. Removed in 2026.9.0.", "2026.3.0")
|
||||
float gamma_correct(float value, float gamma);
|
||||
/// Reverts gamma correction of \p gamma to \p value.
|
||||
// Remove before 2026.9.0
|
||||
ESPDEPRECATED("Use LightState::gamma_uncorrect_lut() instead. Removed in 2026.9.0.", "2026.3.0")
|
||||
float gamma_uncorrect(float value, float gamma);
|
||||
|
||||
/// Convert \p red, \p green and \p blue (all 0-1) values to \p hue (0-360), \p saturation (0-1) and \p value (0-1).
|
||||
void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value);
|
||||
/// Convert \p hue (0-360), \p saturation (0-1) and \p value (0-1) to \p red, \p green and \p blue (all 0-1).
|
||||
|
||||
@@ -60,16 +60,6 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *form
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_STORE_LOG_STR_IN_FLASH
|
||||
// Remove before 2026.9.0
|
||||
void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) {
|
||||
#ifdef USE_LOGGER
|
||||
ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr);
|
||||
logger::global_logger->log_vprintf_(static_cast<uint8_t>(level), tag, line, format, args);
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP32
|
||||
int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT
|
||||
#ifdef USE_LOGGER
|
||||
|
||||
@@ -68,11 +68,6 @@ void esp_log_printf_(int level, const char *tag, int line, const char *format, .
|
||||
void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...);
|
||||
#endif
|
||||
void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT
|
||||
#ifdef USE_STORE_LOG_STR_IN_FLASH
|
||||
// Remove before 2026.9.0
|
||||
__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_(
|
||||
int level, const char *tag, int line, const __FlashStringHelper *format, va_list args);
|
||||
#endif
|
||||
#if defined(USE_ESP32)
|
||||
int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT
|
||||
#endif
|
||||
|
||||
@@ -16,6 +16,7 @@ from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__
|
||||
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
from esphome.helpers import write_file
|
||||
from esphome.net_retry import fetch_with_retry
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -157,8 +158,17 @@ def has_remote_file_changed(
|
||||
}
|
||||
if etag := _read_etag(local_file_path):
|
||||
headers[IF_NONE_MATCH] = etag
|
||||
response = requests.head(
|
||||
url, headers=headers, timeout=timeout, allow_redirects=True
|
||||
# Retried so allow_stale=False consumers don't hard-fail on a
|
||||
# healed flake. Only connection-level failures retry: HEAD
|
||||
# never raises on HTTP status (servers rejecting HEAD with
|
||||
# 405/501 must fall through to the GET), so 5xx is handled by
|
||||
# the GET's own retry.
|
||||
response = fetch_with_retry(
|
||||
url,
|
||||
lambda: requests.head(
|
||||
url, headers=headers, timeout=timeout, allow_redirects=True
|
||||
),
|
||||
what="Revalidation",
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
@@ -293,7 +303,7 @@ def download_content(
|
||||
_LOGGER.info("Downloading %s", url)
|
||||
_LOGGER.debug("Saving to %s", path)
|
||||
|
||||
try:
|
||||
def _fetch() -> tuple[requests.Response, bytes]:
|
||||
req = requests.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
@@ -304,7 +314,10 @@ def download_content(
|
||||
# and mid-stream connection errors all surface here as
|
||||
# RequestException subclasses, so this needs the same fall-back
|
||||
# treatment as the request itself.
|
||||
data = req.content
|
||||
return req, req.content
|
||||
|
||||
try:
|
||||
req, data = fetch_with_retry(url, _fetch)
|
||||
except requests.exceptions.RequestException as e:
|
||||
if path.exists():
|
||||
# Memoized so a flaky host warns once per run, not per consumer.
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import IO, TYPE_CHECKING
|
||||
|
||||
from esphome.happy_eyeballs import ensure_happy_eyeballs
|
||||
from esphome.helpers import ProgressBar, rmtree
|
||||
from esphome.net_retry import NETWORK_MAX_ATTEMPTS, is_transient_download_error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import requests
|
||||
@@ -29,8 +30,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_MIRROR_ATTEMPTS = 3
|
||||
|
||||
# Passes over the whole mirror list when a transient network error is in
|
||||
# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff).
|
||||
_MIRROR_SWEEP_ATTEMPTS = 3
|
||||
# the mix; shares net_retry's policy (3 tries, 2s/4s backoff), which in
|
||||
# turn matches git.py's _NETWORK_MAX_ATTEMPTS.
|
||||
_MIRROR_SWEEP_ATTEMPTS = NETWORK_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def get_project_link_flags() -> list[str]:
|
||||
@@ -903,30 +905,6 @@ def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
|
||||
return err
|
||||
|
||||
|
||||
def _is_transient_download_error(e: Exception) -> bool:
|
||||
"""Return True when a download failure is worth retrying.
|
||||
|
||||
Connection-level failures and HTTP 429/5xx are transient. Other HTTP
|
||||
errors, local errors, and exhausted-attempts EsphomeError wrappers
|
||||
(their per-mirror retries are already spent) are permanent.
|
||||
"""
|
||||
# Imported lazily: requests is a heavy import (~85ms) and is only
|
||||
# needed when actually downloading, never during config validation.
|
||||
import requests
|
||||
|
||||
if isinstance(e, requests.exceptions.HTTPError):
|
||||
resp = e.response
|
||||
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
|
||||
return isinstance(
|
||||
e,
|
||||
(
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ChunkedEncodingError,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _try_mirrors_once(
|
||||
urls: list[str],
|
||||
path_target: Path | None,
|
||||
@@ -1131,7 +1109,7 @@ def download_from_mirrors(
|
||||
# Permanent failures (404, verification mismatch) won't heal;
|
||||
# only retry when a transient error is in the mix (as git.py does).
|
||||
transient = next(
|
||||
((u, e) for u, e in sweep_failures if _is_transient_download_error(e)),
|
||||
((u, e) for u, e in sweep_failures if is_transient_download_error(e)),
|
||||
None,
|
||||
)
|
||||
if transient is None:
|
||||
|
||||
+12
-1
@@ -552,7 +552,18 @@ def write_file_if_changed(path: Path, text: str) -> bool:
|
||||
"""
|
||||
src_content = None
|
||||
if path.is_file():
|
||||
src_content = read_file(path)
|
||||
try:
|
||||
src_content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as err:
|
||||
# Replace a damaged file rather than abort the regeneration that
|
||||
# fixes it; an OSError may hide an intact file, so it still raises
|
||||
_LOGGER.warning("Replacing damaged file %s: %s", path, err)
|
||||
with suppress(OSError):
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"Error reading file {path}: {err}") from err
|
||||
if src_content == text:
|
||||
return False
|
||||
write_file(path, text)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Retry policy for HTTP downloads.
|
||||
|
||||
Kept import-light on purpose: this module is imported at config time, so it
|
||||
must not pull in requests (a heavy import, ~85ms) at module scope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
import time
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 3 tries with 2s/4s backoff, matching git.py's _NETWORK_MAX_ATTEMPTS.
|
||||
# Callers memoize failures so a flaky host pays this once per file per run.
|
||||
NETWORK_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _is_permanent_dns_failure(e: BaseException) -> bool:
|
||||
"""Whether a hard socket.gaierror hides in ``e``'s exception chain.
|
||||
|
||||
EAI_AGAIN (flaky resolver) stays retryable; anything else is permanent
|
||||
so offline builds fall back to their cache without sleeping first.
|
||||
Narrower than git.py, which retries NXDOMAIN too.
|
||||
|
||||
Walks ``__cause__``, ``args`` (requests wraps MaxRetryError without
|
||||
``from``) and MaxRetryError's ``reason``, but not implicit
|
||||
``__context__``: an unrelated earlier attempt's resolution failure
|
||||
must not reclassify an error it did not cause.
|
||||
"""
|
||||
import socket
|
||||
|
||||
seen: set[int] = set()
|
||||
stack: list[BaseException] = [e]
|
||||
while stack:
|
||||
exc = stack.pop()
|
||||
if id(exc) in seen:
|
||||
continue
|
||||
if (
|
||||
isinstance(exc, socket.gaierror)
|
||||
and exc.errno is not None
|
||||
and exc.errno != socket.EAI_AGAIN
|
||||
):
|
||||
return True
|
||||
seen.add(id(exc))
|
||||
stack.extend(
|
||||
nxt
|
||||
for nxt in (
|
||||
exc.__cause__,
|
||||
getattr(exc, "reason", None), # urllib3 MaxRetryError
|
||||
*exc.args,
|
||||
)
|
||||
if isinstance(nxt, BaseException)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def is_transient_download_error(e: Exception) -> bool:
|
||||
"""Return True when a download failure is worth retrying.
|
||||
|
||||
Connection-level failures and HTTP 429/5xx are transient; hard DNS
|
||||
failures, other HTTP errors, and local errors are permanent.
|
||||
"""
|
||||
# Imported lazily: requests is a heavy import (~85ms) and is only
|
||||
# needed when actually downloading, never during config validation.
|
||||
import requests
|
||||
|
||||
if isinstance(e, requests.exceptions.HTTPError):
|
||||
resp = e.response
|
||||
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
|
||||
if isinstance(e, requests.exceptions.ConnectionError) and _is_permanent_dns_failure(
|
||||
e
|
||||
):
|
||||
return False
|
||||
# SSLError (a ConnectionError subclass) stays transient on purpose: it
|
||||
# also covers mid-handshake connection drops, not just bad certificates.
|
||||
return isinstance(
|
||||
e,
|
||||
(
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ChunkedEncodingError,
|
||||
requests.exceptions.ContentDecodingError,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def fetch_with_retry[T](url: str, fetch: Callable[[], T], what: str = "Download") -> T:
|
||||
"""Run ``fetch``, retrying transient failures with 2s/4s backoff.
|
||||
|
||||
Permanent failures and the final attempt propagate to the caller;
|
||||
``what`` names the operation in the retry warning.
|
||||
"""
|
||||
import requests
|
||||
|
||||
for attempt in range(1, NETWORK_MAX_ATTEMPTS):
|
||||
try:
|
||||
return fetch()
|
||||
except requests.exceptions.RequestException as e:
|
||||
if not is_transient_download_error(e):
|
||||
raise
|
||||
delay = 2**attempt
|
||||
_LOGGER.warning(
|
||||
"%s of %s failed: %s. Retrying in %d seconds... (attempt %d/%d)",
|
||||
what,
|
||||
url,
|
||||
e,
|
||||
delay,
|
||||
attempt + 1,
|
||||
NETWORK_MAX_ATTEMPTS,
|
||||
)
|
||||
time.sleep(delay)
|
||||
return fetch()
|
||||
+2
-19
@@ -337,29 +337,12 @@ def copy_src_tree():
|
||||
else:
|
||||
try:
|
||||
existing = json.loads(build_info_json_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(existing, dict) or (
|
||||
if (
|
||||
existing.get("config_hash") != config_hash
|
||||
or existing.get("esphome_version") != __version__
|
||||
):
|
||||
# Non-object JSON is stale like every other damage case
|
||||
sources_changed = True
|
||||
except FileNotFoundError:
|
||||
# An absent build_info.json is stale, not damaged; rebuild quietly
|
||||
sources_changed = True
|
||||
except (ValueError, OSError) as err:
|
||||
# ValueError covers both JSONDecodeError and UnicodeDecodeError;
|
||||
# unlink so the regenerating write never re-reads the bad copy.
|
||||
# "Unreadable" not "damaged": EACCES/EISDIR land here too
|
||||
_LOGGER.warning("Regenerating unreadable build_info.json: %s", err)
|
||||
try:
|
||||
# missing_ok: a concurrent clean may have removed it already
|
||||
build_info_json_path.unlink(missing_ok=True)
|
||||
except OSError as unlink_err:
|
||||
# The later write re-reads the file, so a kept unreadable copy
|
||||
# fails again with a misattributed error; name the real cause
|
||||
_LOGGER.warning(
|
||||
"Could not remove unreadable build_info.json: %s", unlink_err
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, OSError):
|
||||
sources_changed = True
|
||||
|
||||
# Write build_info header and JSON metadata
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==45.13.1
|
||||
aioesphomeapi==46.0.0
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.150.0
|
||||
puremagic==2.2.0
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
esphome:
|
||||
name: scan-window-explicit
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
window: 30ms
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: scan-window-raised
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: scan-window-scan-only
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: scan-window-user-scan-only
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
connection_scan_window: 20ms
|
||||
@@ -12,11 +12,12 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.ble_device_base import to_ble_units
|
||||
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW, to_ble_units
|
||||
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
from esphome.components.esp32 import KEY_IDF_VERSION
|
||||
from esphome.components.esp32_ble_tracker import (
|
||||
@@ -120,3 +121,103 @@ def test_short_interval_without_window_still_rejected(
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
|
||||
_scan_params({"scan_parameters": {"interval": "20ms"}})
|
||||
|
||||
|
||||
# The connection-time fallback window: while a GATT connection is active the
|
||||
# scanner drops from a raised full-duty window back to this value so the
|
||||
# connection gets guaranteed airtime.
|
||||
|
||||
|
||||
def test_raise_arms_connection_scan_window_default(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({})
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 48
|
||||
|
||||
|
||||
def test_user_connection_scan_window_survives_raise(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
params = _scan_params({"scan_parameters": {"connection_scan_window": "60ms"}})
|
||||
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 96
|
||||
|
||||
|
||||
def test_unraised_window_gets_no_connection_scan_window_default(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.4", wifi=True)
|
||||
assert CONF_CONNECTION_SCAN_WINDOW not in _scan_params({})
|
||||
|
||||
|
||||
def test_connection_scan_window_above_interval_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="connection_scan_window .* needs to be smaller"
|
||||
):
|
||||
_scan_params({"scan_parameters": {"connection_scan_window": "400ms"}})
|
||||
|
||||
|
||||
def test_connection_scan_window_above_window_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""A connection window above the (post-raise) window would widen the scan
|
||||
during connections; the reject runs after the raise so a fallback below a
|
||||
raised window still validates (covered by the survives-raise test)."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="connection_scan_window .* needs to be smaller"
|
||||
):
|
||||
_scan_params(
|
||||
{"scan_parameters": {"window": "30ms", "connection_scan_window": "300ms"}}
|
||||
)
|
||||
|
||||
|
||||
def test_connection_scan_window_truncation_collapse_rejected(
|
||||
stage_esp32: Callable[..., None],
|
||||
) -> None:
|
||||
"""A connection window that truncates into the interval's 0.625 ms unit
|
||||
would silently program a full-duty scan during connections."""
|
||||
stage_esp32("5.5.5", wifi=True)
|
||||
with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"):
|
||||
_scan_params(
|
||||
{
|
||||
"scan_parameters": {
|
||||
"interval": "320.5ms",
|
||||
"connection_scan_window": "320.2ms",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "window_call", "connection_call", "warns"),
|
||||
[
|
||||
# Raised window with GATT clients: the injected fallback is emitted.
|
||||
("scan_window_raised.yaml", "set_scan_window(512)", True, False),
|
||||
# Explicit window: nothing injected.
|
||||
("scan_window_explicit.yaml", "set_scan_window(48)", False, False),
|
||||
# Scan-only build compiles the path out: the injected default is
|
||||
# dropped silently, a user-set value warns.
|
||||
("scan_window_scan_only.yaml", "set_scan_window(512)", False, False),
|
||||
("scan_window_user_set_scan_only.yaml", "set_scan_window(512)", False, True),
|
||||
],
|
||||
)
|
||||
def test_connection_scan_window_codegen(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
config_file: str,
|
||||
window_call: str,
|
||||
connection_call: bool,
|
||||
warns: bool,
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path(config_file))
|
||||
assert window_call in main_cpp
|
||||
assert ("set_connection_scan_window(48)" in main_cpp) == connection_call
|
||||
assert ("'connection_scan_window' has no effect" in caplog.text) == warns
|
||||
|
||||
@@ -16,7 +16,13 @@ from esphome.components.modbus_client import (
|
||||
CONFIG_SCHEMA,
|
||||
MODBUS_CLIENT_SEND_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_ID,
|
||||
CONF_ON_ERROR,
|
||||
CONF_ON_RESPONSE,
|
||||
)
|
||||
from esphome.core import Lambda
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -118,6 +124,29 @@ def test_on_no_response_retry_lambda_accepted() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_on_write_pdu_rejected() -> None:
|
||||
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
|
||||
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0x01,
|
||||
CONF_PDU: [0x06, 0x00, 0x01, 0x00, 0x0A],
|
||||
CONF_CONTINUOUS: True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_on_read_pdu_accepted() -> None:
|
||||
"""A literal read-code PDU with continuous: true is fine - continuous polling applies to reads."""
|
||||
MODBUS_CLIENT_SEND_SCHEMA(
|
||||
{
|
||||
CONF_ADDRESS: 0x01,
|
||||
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
|
||||
CONF_CONTINUOUS: True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
|
||||
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
|
||||
# components rather than this one.
|
||||
|
||||
@@ -136,3 +136,19 @@ binary_sensor:
|
||||
invalid_cooldown: 2s
|
||||
then:
|
||||
- logger.log: "Click with custom cooldown"
|
||||
|
||||
# Test on_click and on_double_click (compiles match_interval via
|
||||
# USE_BINARY_SENSOR_CLICK_TRIGGER)
|
||||
- platform: template
|
||||
id: click_triggers
|
||||
name: "Click Triggers"
|
||||
on_click:
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Clicked"
|
||||
on_double_click:
|
||||
min_length: 50ms
|
||||
max_length: 350ms
|
||||
then:
|
||||
- logger.log: "Double clicked"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# No host camera platform exists to emit USE_CAMERA; define it here so
|
||||
# the iterator CAMERA state compiles into the test binary.
|
||||
async def to_code_testing(config):
|
||||
cg.add_define("USE_CAMERA")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,79 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/component_iterator.h"
|
||||
|
||||
#ifdef USE_CAMERA
|
||||
#include "esphome/components/camera/camera.h"
|
||||
|
||||
namespace esphome::testing {
|
||||
|
||||
class StubCamera : public camera::Camera {
|
||||
public:
|
||||
void add_listener(camera::CameraListener *listener) override {}
|
||||
camera::CameraImageReader *create_image_reader() override { return nullptr; }
|
||||
void request_image(camera::CameraRequester requester) override {}
|
||||
void start_stream(camera::CameraRequester requester) override {}
|
||||
void stop_stream(camera::CameraRequester requester) override {}
|
||||
};
|
||||
|
||||
// Iterator that accepts everything except the camera, which can refuse a
|
||||
// configurable number of times. The CAMERA state is a singleton path
|
||||
// distinct from process_platform_item_; this pins the same contract:
|
||||
// a refused camera is re-offered, never skipped.
|
||||
class CameraRefusingIterator : public ComponentIterator {
|
||||
public:
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
bool on_##singular(type *obj) override { return true; }
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
|
||||
bool on_camera(camera::Camera *obj) override {
|
||||
this->camera_calls++;
|
||||
if (this->camera_refusals > 0) {
|
||||
this->camera_refusals--;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int camera_calls{0};
|
||||
int camera_refusals{0};
|
||||
};
|
||||
|
||||
// Far above the fixed number of iterator states
|
||||
static constexpr size_t BIG_BUDGET = 1000;
|
||||
|
||||
class ComponentIteratorCameraTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
// Constructing a Camera installs the process-wide singleton
|
||||
static StubCamera stub_camera;
|
||||
ASSERT_EQ(camera::Camera::instance(), &stub_camera);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(ComponentIteratorCameraTest, RefusedCameraIsReofferedNotSkipped) {
|
||||
CameraRefusingIterator it;
|
||||
it.camera_refusals = 2;
|
||||
it.begin();
|
||||
// Runs until the camera refuses, which stops the pass
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.camera_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The camera is re-offered once per call, not skipped
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.camera_calls, 2);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// Once accepted, the iteration completes
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.camera_calls, 3);
|
||||
}
|
||||
|
||||
} // namespace esphome::testing
|
||||
#endif // USE_CAMERA
|
||||
@@ -0,0 +1,11 @@
|
||||
# Pulls in sensor so entity iteration paths compile (USE_SENSOR);
|
||||
# tests register their own instances. Plain yaml.safe_load, no ESPHome tags.
|
||||
# An alphabetically-earlier component's sensor: block shadows this one in
|
||||
# combined builds; the tests' sensor-count ASSERT catches a capacity drop.
|
||||
sensor:
|
||||
- platform: template
|
||||
id: bench_sensor_a
|
||||
name: "Bench A"
|
||||
- platform: template
|
||||
id: bench_sensor_b
|
||||
name: "Bench B"
|
||||
@@ -0,0 +1,195 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/component_iterator.h"
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/application.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::testing {
|
||||
|
||||
// Iterator whose begin/end callbacks can refuse a configurable number of
|
||||
// times; all entity callbacks accept (any registered entities are accepted).
|
||||
class RefusingIterator : public ComponentIterator {
|
||||
public:
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
bool on_##singular(type *obj) override { return true; }
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
|
||||
bool on_begin() override { return step(this->begin_calls, this->begin_refusals); }
|
||||
bool on_end() override { return step(this->end_calls, this->end_refusals); }
|
||||
|
||||
int begin_calls{0};
|
||||
int end_calls{0};
|
||||
int begin_refusals{0};
|
||||
int end_refusals{0};
|
||||
|
||||
protected:
|
||||
static bool step(int &calls, int &refusals) {
|
||||
calls++;
|
||||
if (refusals > 0) {
|
||||
refusals--;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Far above the fixed number of iterator states
|
||||
static constexpr size_t BIG_BUDGET = 1000;
|
||||
|
||||
TEST(ComponentIterator, NotRunningMakesNoProgress) {
|
||||
RefusingIterator it;
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.begin_calls, 0);
|
||||
EXPECT_EQ(it.end_calls, 0);
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, CompletesInOneCallWithoutRefusals) {
|
||||
RefusingIterator it;
|
||||
it.begin();
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.begin_calls, 1);
|
||||
EXPECT_EQ(it.end_calls, 1);
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, StepBudgetIsHonored) {
|
||||
RefusingIterator it;
|
||||
it.begin();
|
||||
it.try_advance(1);
|
||||
EXPECT_EQ(it.begin_calls, 1);
|
||||
EXPECT_EQ(it.end_calls, 0);
|
||||
EXPECT_FALSE(it.completed());
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, RefusedStepStopsBatchAndRetriesSameStep) {
|
||||
RefusingIterator it;
|
||||
it.end_refusals = 3;
|
||||
it.begin();
|
||||
// First call runs until the refused end step, which stops the pass
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.end_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The refused step is retried once per call, not skipped
|
||||
it.try_advance(BIG_BUDGET);
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.end_calls, 3);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// Once accepted, the iteration completes
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.end_calls, 4);
|
||||
}
|
||||
|
||||
TEST(ComponentIterator, RefusedBeginStopsBatchAndRetries) {
|
||||
RefusingIterator it;
|
||||
it.begin_refusals = 2;
|
||||
it.begin();
|
||||
it.try_advance(BIG_BUDGET);
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.begin_calls, 2);
|
||||
EXPECT_FALSE(it.completed());
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_EQ(it.begin_calls, 3);
|
||||
}
|
||||
|
||||
// The deprecated advance() wrapper must keep the legacy once-per-loop
|
||||
// pattern working during the deprecation window.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
TEST(ComponentIterator, DeprecatedAdvanceKeepsLegacyPatternWorking) {
|
||||
RefusingIterator it;
|
||||
it.end_refusals = 2;
|
||||
it.begin();
|
||||
size_t guard = 0;
|
||||
while (!it.completed() && guard++ < BIG_BUDGET) {
|
||||
it.advance();
|
||||
}
|
||||
EXPECT_TRUE(it.completed());
|
||||
// Two refused end steps were retried, then accepted
|
||||
EXPECT_EQ(it.end_calls, 3);
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// Iterator whose sensor callback can refuse or yield; pins the per-item
|
||||
// contract: a refused item is re-offered with at_ unchanged, never skipped.
|
||||
class ItemRefusingIterator : public RefusingIterator {
|
||||
public:
|
||||
bool on_sensor(sensor::Sensor *obj) override {
|
||||
this->last_sensor = obj;
|
||||
if (!step(this->sensor_calls, this->sensor_refusals))
|
||||
return false;
|
||||
if (this->yield_on_sensor)
|
||||
this->yield_after_step_();
|
||||
return true;
|
||||
}
|
||||
sensor::Sensor *last_sensor{nullptr};
|
||||
int sensor_calls{0};
|
||||
int sensor_refusals{0};
|
||||
bool yield_on_sensor{false};
|
||||
};
|
||||
|
||||
class ComponentIteratorSensorTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
static sensor::Sensor sensor_a;
|
||||
static sensor::Sensor sensor_b;
|
||||
static bool registered = false;
|
||||
if (!registered) {
|
||||
App.register_sensor(&sensor_a);
|
||||
App.register_sensor(&sensor_b);
|
||||
registered = true;
|
||||
}
|
||||
// StaticVector drops silently when full; fail the fixture, not the contract
|
||||
ASSERT_EQ(App.get_sensors().size(), 2u) << "benchmark.yaml sensor count too small";
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(ComponentIteratorSensorTest, RefusedItemIsReofferedNotSkipped) {
|
||||
ItemRefusingIterator it;
|
||||
it.sensor_refusals = 2;
|
||||
it.begin();
|
||||
// Runs until the first sensor refuses
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The refused item is re-offered, not skipped
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 2);
|
||||
sensor::Sensor *refused = it.last_sensor;
|
||||
// Once accepted, iteration continues through the second sensor to the end
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
EXPECT_NE(it.last_sensor, refused);
|
||||
EXPECT_EQ(it.sensor_calls, 4);
|
||||
}
|
||||
|
||||
TEST_F(ComponentIteratorSensorTest, YieldAfterStepEndsPassAndResumes) {
|
||||
ItemRefusingIterator it;
|
||||
it.yield_on_sensor = true;
|
||||
it.begin();
|
||||
// The pass ends right after the first sensor despite a big budget
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 1);
|
||||
EXPECT_FALSE(it.completed());
|
||||
// The next pass ends after the second sensor
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_EQ(it.sensor_calls, 2);
|
||||
// Remaining states then run to completion in one pass
|
||||
it.try_advance(BIG_BUDGET);
|
||||
EXPECT_TRUE(it.completed());
|
||||
}
|
||||
#endif // USE_SENSOR
|
||||
|
||||
} // namespace esphome::testing
|
||||
@@ -2,10 +2,19 @@
|
||||
#include <utility>
|
||||
#include "../common.h"
|
||||
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
struct MitsubishiCN105ClimateTestContext {
|
||||
MitsubishiCN105Component component;
|
||||
MitsubishiCN105Climate sut;
|
||||
|
||||
MitsubishiCN105ClimateTestContext() { this->sut.set_parent(&this->component); }
|
||||
};
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
const auto mapping = TemperatureMapping();
|
||||
|
||||
for (int temperature = 16; temperature <= 31; ++temperature) {
|
||||
@@ -13,7 +22,7 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
|
||||
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
|
||||
}
|
||||
|
||||
const auto traits = sut.traits();
|
||||
const auto traits = context.sut.traits();
|
||||
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
|
||||
@@ -22,10 +31,10 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
auto mapping = TemperatureMapping();
|
||||
mapping.set_use_fahrenheit(true);
|
||||
sut.set_use_fahrenheit(true);
|
||||
context.component.set_use_fahrenheit(true);
|
||||
|
||||
const std::array cases{
|
||||
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
|
||||
@@ -40,7 +49,7 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpe
|
||||
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
|
||||
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
|
||||
}
|
||||
const auto traits = sut.traits();
|
||||
const auto traits = context.sut.traits();
|
||||
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
|
||||
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
|
||||
@@ -63,163 +72,44 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversi
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
|
||||
|
||||
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
|
||||
EXPECT_FALSE(context.sut.traits().get_supports_swing_modes());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
MitsubishiCN105ClimateTestContext context;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
|
||||
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
#include "../common.h"
|
||||
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
static SwingModeManager make_swing_mode_manager(std::initializer_list<climate::ClimateSwingMode> supported_modes) {
|
||||
SwingModeManager manager;
|
||||
climate::ClimateSwingModeMask supported_swing_modes;
|
||||
for (const auto mode : supported_modes)
|
||||
supported_swing_modes.insert(mode);
|
||||
manager.set_supported_swing_modes(supported_swing_modes);
|
||||
return manager;
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsVerticalSwingWhenSupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
|
||||
std::optional{climate::CLIMATE_SWING_VERTICAL});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsHorizontalSwingWhenSupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
|
||||
std::optional{climate::CLIMATE_SWING_HORIZONTAL});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsBothSwingWhenSupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING),
|
||||
std::optional{climate::CLIMATE_SWING_BOTH});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, StatusMapsSwingOffWhenNoSwingActive) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
EXPECT_EQ(
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::WideVaneMode::CENTER),
|
||||
std::optional{climate::CLIMATE_SWING_OFF});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, RemembersLastNonSwingPositions) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING);
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_4});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::RIGHT});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, UnknownValuesDoNotOverwriteRememberedPositions) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::WideVaneMode::LEFT);
|
||||
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::UNKNOWN, MitsubishiCN105::WideVaneMode::UNKNOWN);
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_2});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::LEFT});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, UnsupportedVerticalSwingStateIsIgnored) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
|
||||
std::optional{climate::CLIMATE_SWING_OFF});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, UnsupportedHorizontalSwingStateIsIgnored) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
|
||||
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
|
||||
std::optional{climate::CLIMATE_SWING_OFF});
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, SwingModeFromReturnsNulloptWhenNoSwingModesSupported) {
|
||||
auto manager = make_swing_mode_manager({});
|
||||
EXPECT_FALSE(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING)
|
||||
.has_value());
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, VaneFromSwingModeReturnsNulloptWhenVerticalUnsupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
|
||||
EXPECT_FALSE(manager.vane_from(climate::CLIMATE_SWING_VERTICAL).has_value());
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, WideVaneFromSwingModeReturnsNulloptWhenHorizontalUnsupported) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
|
||||
EXPECT_FALSE(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL).has_value());
|
||||
}
|
||||
|
||||
TEST(SwingModeManagerTests, VaneAndWideVaneFromSwingModeMapSwingModes) {
|
||||
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_VERTICAL), std::optional{MitsubishiCN105::VaneMode::SWING});
|
||||
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::VaneMode::SWING});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL),
|
||||
std::optional{MitsubishiCN105::WideVaneMode::SWING});
|
||||
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::WideVaneMode::SWING});
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
@@ -64,26 +64,4 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
|
||||
};
|
||||
|
||||
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
|
||||
public:
|
||||
TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); }
|
||||
|
||||
using MitsubishiCN105Climate::apply_values_;
|
||||
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
|
||||
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
|
||||
|
||||
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
|
||||
void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); }
|
||||
|
||||
protected:
|
||||
MitsubishiCN105Component component_;
|
||||
};
|
||||
|
||||
class TestableMitsubishiCN105Component : public MitsubishiCN105Component {
|
||||
public:
|
||||
MitsubishiCN105::Status &mutable_status() { return const_cast<MitsubishiCN105::Status &>(this->status()); }
|
||||
|
||||
void notify_status() { this->status_callback_.call(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
size_t callback_count = 0;
|
||||
std::optional<VerticalVaneMode> callback_direction;
|
||||
hub.add_on_vane_state_callback([&](const VaneState &state) {
|
||||
@@ -11,8 +11,9 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
callback_direction = state.vertical.direction;
|
||||
});
|
||||
|
||||
hub.mutable_status().room_temperature = 20.0f;
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
hub.set_target_temperature(20.0f);
|
||||
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(callback_count, 1);
|
||||
@@ -25,7 +26,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
size_t status_callback_count = 0;
|
||||
size_t vane_callback_count = 0;
|
||||
std::optional<VerticalVaneMode> callback_direction;
|
||||
@@ -35,15 +36,16 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
callback_direction = state.vertical.direction;
|
||||
});
|
||||
|
||||
hub.mutable_status().room_temperature = 20.0f;
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
hub.set_target_temperature(20.0f);
|
||||
ASSERT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(status_callback_count, 1);
|
||||
EXPECT_EQ(vane_callback_count, 1);
|
||||
EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN});
|
||||
|
||||
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
|
||||
hub.publish_status();
|
||||
|
||||
EXPECT_EQ(status_callback_count, 2);
|
||||
@@ -52,7 +54,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
|
||||
auto call = hub.make_vane_call();
|
||||
call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5);
|
||||
@@ -62,12 +64,11 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
MitsubishiCN105Component hub;
|
||||
VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); });
|
||||
|
||||
action.play();
|
||||
|
||||
EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING);
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
+15
-18
@@ -3,14 +3,9 @@
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
|
||||
public:
|
||||
using MitsubishiCN105VerticalVaneDirectionSelect::control;
|
||||
};
|
||||
|
||||
struct VerticalVaneDirectionSelectTestContext {
|
||||
TestableMitsubishiCN105Component hub;
|
||||
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
|
||||
MitsubishiCN105Component hub;
|
||||
MitsubishiCN105VerticalVaneDirectionSelect select;
|
||||
|
||||
VerticalVaneDirectionSelectTestContext() {
|
||||
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
|
||||
@@ -31,13 +26,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
|
||||
|
||||
for (size_t i = 0; i < expected_modes.size(); ++i) {
|
||||
SCOPED_TRACE(i);
|
||||
ctx.select.control(i);
|
||||
ctx.select.make_call().set_index(i).perform();
|
||||
EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) {
|
||||
VerticalVaneDirectionSelectTestContext ctx;
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
|
||||
constexpr std::array modes{
|
||||
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
|
||||
@@ -48,13 +45,12 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes
|
||||
|
||||
for (size_t i = 0; i < modes.size(); ++i) {
|
||||
SCOPED_TRACE(i);
|
||||
ctx.hub.mutable_status().vane_mode = modes[i];
|
||||
ctx.hub.notify_status();
|
||||
ctx.hub.set_vane_mode(modes[i]);
|
||||
ctx.hub.publish_status();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
|
||||
}
|
||||
|
||||
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
ctx.hub.notify_status();
|
||||
ctx.select.publish_vane_state(MitsubishiCN105::VaneMode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
|
||||
}
|
||||
|
||||
@@ -64,14 +60,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndC
|
||||
climate_entity.set_parent(&ctx.hub);
|
||||
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.hub.mutable_status().room_temperature = 20.0f;
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
climate_entity.setup();
|
||||
|
||||
ctx.select.control(6);
|
||||
ctx.select.make_call().set_index(6).perform();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
|
||||
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.select.control(3);
|
||||
ctx.select.make_call().set_index(3).perform();
|
||||
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
|
||||
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
@@ -82,7 +79,8 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
|
||||
climate_entity.set_parent(&ctx.hub);
|
||||
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
ctx.hub.mutable_status().room_temperature = 20.0f;
|
||||
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
|
||||
ctx.hub.set_target_temperature(20.0f);
|
||||
climate_entity.setup();
|
||||
|
||||
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
|
||||
@@ -95,10 +93,9 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
|
||||
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
|
||||
VerticalVaneDirectionSelectTestContext ctx;
|
||||
|
||||
ctx.select.control(3);
|
||||
ctx.select.make_call().set_index(3).perform();
|
||||
|
||||
EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3);
|
||||
EXPECT_FALSE(ctx.select.has_state());
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -322,14 +322,14 @@ TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
hub.force_send_next();
|
||||
|
||||
// A matching successful response cycles the continuous entry back to READY.
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response);
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
// An exception response ends the poll.
|
||||
hub.force_send_next();
|
||||
@@ -346,13 +346,13 @@ TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
hub.force_send_next();
|
||||
|
||||
hub.timeout_waiting(); // no response -> device requests retry
|
||||
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
|
||||
}
|
||||
|
||||
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
|
||||
@@ -363,16 +363,16 @@ TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_FALSE(hub.queued(0).continuous);
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous);
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u);
|
||||
|
||||
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
|
||||
hub.force_send_next();
|
||||
EXPECT_FALSE(hub.waiting_command().continuous);
|
||||
EXPECT_FALSE(hub.waiting_command().options.continuous);
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response);
|
||||
EXPECT_EQ(hub.queued_frames(), 0u);
|
||||
@@ -407,16 +407,16 @@ TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
hub.force_send_next();
|
||||
const uint8_t exception_response[] = {0x83, 0x02};
|
||||
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
|
||||
|
||||
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
|
||||
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
|
||||
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous); // downgraded to a one-shot
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
|
||||
|
||||
// And it runs to its own terminal - a good response this time - then the entry is gone.
|
||||
hub.force_send_next();
|
||||
@@ -434,18 +434,18 @@ TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
|
||||
|
||||
device.read_holding_registers(0x100, 2);
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
ASSERT_FALSE(hub.queued(0).continuous);
|
||||
ASSERT_FALSE(hub.queued(0).options.continuous);
|
||||
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
|
||||
// And it behaves as a poll from here: success cycles it back to READY.
|
||||
hub.force_send_next();
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response);
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_TRUE(hub.queued(0).continuous);
|
||||
EXPECT_TRUE(hub.queued(0).options.continuous);
|
||||
}
|
||||
|
||||
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
|
||||
@@ -473,7 +473,7 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
|
||||
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
|
||||
hub.timeout_waiting();
|
||||
hub.force_send_next();
|
||||
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
|
||||
EXPECT_TRUE(hub.waiting_command().options.continuous); // and the poll takes what is left
|
||||
}
|
||||
|
||||
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
|
||||
@@ -485,7 +485,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
|
||||
device.queue_pdu(write_pdu, {.continuous = true});
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
|
||||
EXPECT_FALSE(hub.queued(0).continuous);
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous);
|
||||
}
|
||||
|
||||
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
|
||||
@@ -496,7 +496,7 @@ TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
|
||||
|
||||
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
|
||||
device.read_holding_registers(0x100, 2, {.continuous = true});
|
||||
ASSERT_TRUE(hub.queued(0).continuous);
|
||||
ASSERT_TRUE(hub.queued(0).options.continuous);
|
||||
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
|
||||
|
||||
device.read_holding_registers(0x200, 2); // a one-shot does count
|
||||
@@ -1878,8 +1878,8 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
|
||||
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
|
||||
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
|
||||
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
|
||||
EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll
|
||||
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
|
||||
}
|
||||
|
||||
// An exception-flagged function code is never silently re-sendable, even though the read check
|
||||
|
||||
@@ -51,6 +51,7 @@ button:
|
||||
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
|
||||
- modbus_client.send:
|
||||
address: 0x01
|
||||
continuous: true
|
||||
pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);"
|
||||
- modbus_client.send:
|
||||
address: !lambda "return 1;"
|
||||
@@ -91,6 +92,7 @@ button:
|
||||
address: !lambda "return 1;"
|
||||
start_address: 0x10
|
||||
count: 2
|
||||
continuous: true
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
|
||||
@@ -98,6 +100,7 @@ button:
|
||||
then:
|
||||
- logger.log: "typed read timeout"
|
||||
- modbus_client.read_input_registers:
|
||||
continuous: !lambda "return false;"
|
||||
address: 0x01
|
||||
start_address: 0x20
|
||||
on_custom_response:
|
||||
@@ -113,12 +116,14 @@ button:
|
||||
address: 0x01
|
||||
start_address: 0x03
|
||||
count: 16
|
||||
continuous: true
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());'
|
||||
- modbus_client.read_discrete_inputs:
|
||||
address: 0x01
|
||||
start_address: 0x00
|
||||
continuous: true
|
||||
on_error:
|
||||
then:
|
||||
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
|
||||
|
||||
@@ -57,6 +57,11 @@ image:
|
||||
url: http://www.faqs.org/images/library.jpg
|
||||
format: JPG
|
||||
type: RGB565
|
||||
- platform: online_image
|
||||
id: online_auto_image
|
||||
url: http://www.faqs.org/images/library.jpg
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
|
||||
# Check the set_url action
|
||||
esphome:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the
|
||||
// erased watermark must always cover the write end, stay 64 KiB block-aligned
|
||||
// until the clamp, and never exceed the partition.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
|
||||
namespace esphome::ota::testing {
|
||||
|
||||
static constexpr size_t BLOCK = 64 * 1024;
|
||||
static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size
|
||||
|
||||
TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, ClampsToPartitionEnd) {
|
||||
// Partition sizes are sector multiples but not always block multiples
|
||||
constexpr size_t part = 27 * BLOCK + 4096;
|
||||
EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part);
|
||||
EXPECT_EQ(next_erase_end(part, part), part);
|
||||
}
|
||||
|
||||
// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for
|
||||
// a write past that seed must still cover the write end.
|
||||
TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); }
|
||||
|
||||
TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) {
|
||||
for (size_t end = 1; end <= PART; end += 4093) {
|
||||
const size_t erased = next_erase_end(end, PART);
|
||||
ASSERT_GE(erased, end);
|
||||
ASSERT_LE(erased, PART);
|
||||
// Block-aligned unless clamped at the partition end
|
||||
ASSERT_TRUE(erased == PART || erased % BLOCK == 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ota::testing
|
||||
@@ -77,18 +77,12 @@ class TestableRuntimeImage : public RuntimeImage {
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
|
||||
|
||||
ImageDecoder *decoder() { return this->decoder_.get(); }
|
||||
|
||||
/// Simulates the state a dynamic-format producer (PR #16337) would leave behind:
|
||||
/// a cached decoder whose format no longer matches the image's format.
|
||||
/// TODO: once #16337 adds a public way to change the format, drive the mismatch
|
||||
/// through it and delete this seam.
|
||||
void plant_decoder(ImageFormat format) { this->decoder_ = this->create_decoder_(format); }
|
||||
};
|
||||
|
||||
/// Runs one full decode session. Returns true when every stage succeeded.
|
||||
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) {
|
||||
static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len, ImageFormat format = AUTO) {
|
||||
std::vector<uint8_t> buffer(data, data + len); // feed_data needs mutable bytes
|
||||
if (!img.begin_decode(len)) {
|
||||
if (!img.begin_decode(len, format)) {
|
||||
return false;
|
||||
}
|
||||
size_t offset = 0;
|
||||
@@ -203,25 +197,51 @@ TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) {
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) {
|
||||
// PNG image holding a stale BMP decoder: begin_decode must evict and recreate.
|
||||
TestableRuntimeImage png_img(PNG);
|
||||
png_img.plant_decoder(BMP);
|
||||
ASSERT_NE(png_img.decoder(), nullptr);
|
||||
ASSERT_EQ(png_img.decoder()->get_format(), BMP);
|
||||
// Drive the format switch through begin_decode()'s format parameter, the way
|
||||
// a dynamic-format producer (online_image MIME detection) does.
|
||||
TestableRuntimeImage img(AUTO);
|
||||
|
||||
ASSERT_TRUE(decode_all(png_img, PNG_RGB, sizeof(PNG_RGB)));
|
||||
EXPECT_EQ(png_img.decoder()->get_format(), PNG);
|
||||
expect_pixels(png_img, PNG_RGB_EXPECTED);
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
|
||||
ASSERT_NE(img.decoder(), nullptr);
|
||||
ASSERT_EQ(img.decoder()->get_format(), BMP);
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
|
||||
// And the other direction: BMP image holding a stale PNG decoder.
|
||||
TestableRuntimeImage bmp_img(BMP);
|
||||
bmp_img.plant_decoder(PNG);
|
||||
ASSERT_NE(bmp_img.decoder(), nullptr);
|
||||
ASSERT_EQ(bmp_img.decoder()->get_format(), PNG);
|
||||
// Same explicit format again: the decoder must stay warm.
|
||||
ImageDecoder *bmp_decoder = img.decoder();
|
||||
ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP), BMP));
|
||||
expect_pixels(img, BMP_8BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), bmp_decoder);
|
||||
|
||||
ASSERT_TRUE(decode_all(bmp_img, BMP_24BPP, sizeof(BMP_24BPP)));
|
||||
EXPECT_EQ(bmp_img.decoder()->get_format(), BMP);
|
||||
expect_pixels(bmp_img, BMP_24BPP_EXPECTED);
|
||||
// Different format: the stale decoder must be evicted and recreated.
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
|
||||
EXPECT_EQ(img.decoder()->get_format(), PNG);
|
||||
expect_pixels(img, PNG_RGB_EXPECTED);
|
||||
|
||||
// And back again.
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), BMP));
|
||||
EXPECT_EQ(img.decoder()->get_format(), BMP);
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, AutoFormatFallsBackToConfiguredAndKeepsDecoderWarm) {
|
||||
// With a configured format, an AUTO begin_decode() must resolve to the
|
||||
// configured format before the reuse check instead of evicting the decoder.
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
EXPECT_EQ(first->get_format(), BMP);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP), AUTO));
|
||||
expect_pixels(img, BMP_24BPP_EXPECTED);
|
||||
EXPECT_EQ(img.decoder(), first) << "AUTO must not evict the configured-format decoder";
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, AutoWithoutConfiguredFormatFails) {
|
||||
// Neither a configured format nor an explicit one: there is nothing to decode with.
|
||||
TestableRuntimeImage img(AUTO);
|
||||
EXPECT_FALSE(img.begin_decode(64));
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) {
|
||||
|
||||
@@ -29,6 +29,7 @@ void setup() {
|
||||
|
||||
auto *ota = new esphome::ESPHomeOTAComponent(); // NOLINT
|
||||
ota->set_port(8266);
|
||||
App.register_component_(ota);
|
||||
|
||||
App.setup();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
|
||||
- `conftest.py` - Common fixtures and utilities
|
||||
- `const.py` - Constants used throughout the integration tests
|
||||
- `types.py` - Type definitions for fixtures and functions
|
||||
- `raw_api_client.py` - Minimal plaintext api client whose reads happen only on request (for backpressure tests)
|
||||
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`)
|
||||
- `fixtures/` - YAML configuration files for tests
|
||||
- `test_*.py` - Individual test files
|
||||
@@ -347,6 +348,7 @@ Create C++ components in `fixtures/external_components/` for:
|
||||
- Custom entity behaviors
|
||||
- Scheduler testing
|
||||
- Memory management tests
|
||||
- Deterministic network backpressure (`sndbuf_pin_component` pins socket send buffers; assert on its log line to prove the pin took effect)
|
||||
|
||||
##### Log Line Monitoring
|
||||
```python
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: api-backpressure-test
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
# Smallest queue so a non-draining client blocks the send path quickly
|
||||
max_send_queue: 1
|
||||
actions:
|
||||
# GENERATED_ACTIONS
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
components: [sndbuf_pin_component]
|
||||
|
||||
# Pins the device's socket send buffers for deterministic TCP backpressure
|
||||
sndbuf_pin_component:
|
||||
buffer_size: SERVER_SNDBUF
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,20 @@
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID
|
||||
|
||||
DEPENDENCIES = ["api"]
|
||||
|
||||
sndbuf_pin_ns = cg.esphome_ns.namespace("sndbuf_pin")
|
||||
SndbufPinComponent = sndbuf_pin_ns.class_("SndbufPinComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SndbufPinComponent),
|
||||
cv.Required(CONF_BUFFER_SIZE): cv.int_range(min=1),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID], config[CONF_BUFFER_SIZE])
|
||||
await cg.register_component(var, config)
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#include "sndbuf_pin_component.h"
|
||||
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <cerrno>
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::sndbuf_pin {
|
||||
|
||||
static const char *const TAG = "sndbuf_pin";
|
||||
|
||||
// Skip stdio; scan the low fd range where the listeners land
|
||||
static constexpr int FIRST_USER_FD = 3;
|
||||
static constexpr int MAX_FD_SCAN = 128;
|
||||
|
||||
void SndbufPinComponent::setup() {
|
||||
int pinned = 0;
|
||||
for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) {
|
||||
int type = 0;
|
||||
socklen_t len = sizeof(type);
|
||||
if (::getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &len) != 0 || type != SOCK_STREAM)
|
||||
continue;
|
||||
struct sockaddr_in addr {};
|
||||
socklen_t addr_len = sizeof(addr);
|
||||
if (::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &addr_len) != 0) {
|
||||
ESP_LOGW(TAG, "fd %d: getsockname failed, errno %d", fd, errno);
|
||||
continue;
|
||||
}
|
||||
if (ntohs(addr.sin_port) != api::global_api_server->get_port())
|
||||
continue;
|
||||
if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &this->buffer_size_, sizeof(this->buffer_size_)) != 0) {
|
||||
ESP_LOGW(TAG, "fd %d: SO_SNDBUF pin failed, errno %d", fd, errno);
|
||||
continue;
|
||||
}
|
||||
int applied = 0;
|
||||
len = sizeof(applied);
|
||||
if (::getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &applied, &len) != 0 || applied < this->buffer_size_) {
|
||||
// Linux doubles the requested value; anything below it means clamped
|
||||
ESP_LOGW(TAG, "fd %d: SO_SNDBUF readback %d below requested %d", fd, applied, this->buffer_size_);
|
||||
continue;
|
||||
}
|
||||
// Tests assert on this line; accepted sockets inherit the pinned size
|
||||
ESP_LOGD(TAG, "fd %d port %d: SO_SNDBUF pinned to %d (effective %d)", fd, ntohs(addr.sin_port), this->buffer_size_,
|
||||
applied);
|
||||
pinned++;
|
||||
}
|
||||
if (pinned == 0) {
|
||||
ESP_LOGE(TAG, "api listener socket was not pinned");
|
||||
this->mark_failed();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::sndbuf_pin
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::sndbuf_pin {
|
||||
|
||||
// Test-only (host): pins SO_SNDBUF on every open TCP socket so integration
|
||||
// tests get deterministic backpressure; an explicit SO_SNDBUF also disables
|
||||
// kernel autotuning, and accepted sockets inherit it from the listener.
|
||||
class SndbufPinComponent : public Component {
|
||||
public:
|
||||
explicit SndbufPinComponent(int buffer_size) : buffer_size_(buffer_size) {}
|
||||
void setup() override;
|
||||
// After the api server so its listening socket exists
|
||||
float get_setup_priority() const override { return setup_priority::LATE; }
|
||||
|
||||
protected:
|
||||
int buffer_size_;
|
||||
};
|
||||
|
||||
} // namespace esphome::sndbuf_pin
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user